Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support namespaced functions #372

Merged
merged 14 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
497 changes: 497 additions & 0 deletions decoder/expr_any_completion_test.go

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions decoder/expr_any_hover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,30 @@ func TestHoverAtPos_exprAny_functions(t *testing.T) {
hcl.Pos{Line: 1, Column: 17, Byte: 16},
nil,
},
{
"over namespaced function",
map[string]*schema.AttributeSchema{
"attr": {
Constraint: schema.AnyExpression{
OfType: cty.String,
},
},
},
`attr = provider::framework::example("FOO")
`,
hcl.Pos{Line: 1, Column: 11, Byte: 10},
&lang.HoverData{
Content: lang.MarkupContent{
Value: "```terraform\nprovider::framework::example(input string) string\n```\n\nEchoes given argument as result\n\nbflad/framework 0.2.0",
Kind: lang.MarkdownKind,
},
Range: hcl.Range{
Filename: "test.tf",
Start: hcl.Pos{Line: 1, Column: 8, Byte: 7},
End: hcl.Pos{Line: 1, Column: 43, Byte: 42},
},
},
},
}

for i, tc := range testCases {
Expand Down
66 changes: 63 additions & 3 deletions decoder/expr_function.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"sort"
"strings"
"unicode/utf8"

"github.com/hashicorp/hcl-lang/lang"
"github.com/hashicorp/hcl-lang/reference"
Expand Down Expand Up @@ -55,6 +56,55 @@ func (fe functionExpr) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.

prefix := rootName[0:prefixLen]
return fe.matchingFunctions(prefix, eType.Range())

case *hclsyntax.ExprSyntaxError:
// Note: this range can range up until the end of the file in case of invalid config
if eType.SrcRange.ContainsPos(pos) {
// we are somewhere in the range for this attribute but we don't have an expression range to check
// so we look back to check whether we are in a partially written provider defined function
fileBytes := fe.pathCtx.Files[eType.SrcRange.Filename].Bytes

recoveredPrefixBytes := recoverLeftBytes(fileBytes, pos, func(offset int, r rune) bool {
return !isNamespacedFunctionNameRune(r)
})
// recoveredPrefixBytes also contains the rune before the function name, so we need to trim it
_, lengthFirstRune := utf8.DecodeRune(recoveredPrefixBytes)
recoveredPrefixBytes = recoveredPrefixBytes[lengthFirstRune:]

recoveredSuffixBytes := recoverRightBytes(fileBytes, pos, func(offset int, r rune) bool {
return !isNamespacedFunctionNameRune(r) && r != '('
})
// recoveredSuffixBytes also contains the rune after the function name, so we need to trim it
_, lengthLastRune := utf8.DecodeLastRune(recoveredSuffixBytes)
recoveredSuffixBytes = recoveredSuffixBytes[:len(recoveredSuffixBytes)-lengthLastRune]

recoveredIdentifier := append(recoveredPrefixBytes, recoveredSuffixBytes...)

// check if our recovered identifier contains "::"
// Why two colons? For no colons the parser would return a traversal expression
// and a single colon will apparently be treated as a traversal and a partial object expression
// (refer to this follow-up issue for more on that case: https://github.com/hashicorp/vscode-terraform/issues/1697)
if bytes.Contains(recoveredIdentifier, []byte("::")) {
editRange := hcl.Range{
Filename: fe.expr.Range().Filename,
Start: hcl.Pos{
Line: pos.Line, // we don't recover newlines, so we can keep the original line number
Byte: pos.Byte - len(recoveredPrefixBytes),
Column: pos.Column - len(recoveredPrefixBytes),
},
End: hcl.Pos{
Line: pos.Line,
Byte: pos.Byte + len(recoveredSuffixBytes),
Column: pos.Column + len(recoveredSuffixBytes),
},
}

return fe.matchingFunctions(string(recoveredPrefixBytes), editRange)
}
}

return []lang.Candidate{}

case *hclsyntax.FunctionCallExpr:
if eType.NameRange.ContainsPos(pos) {
prefixLen := pos.Byte - eType.NameRange.Start.Byte
Expand Down Expand Up @@ -151,9 +201,8 @@ func (fe functionExpr) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverD

if funcExpr.NameRange.ContainsPos(pos) {
return &lang.HoverData{
Content: lang.Markdown(fmt.Sprintf("```terraform\n%s(%s) %s\n```\n\n%s",
funcExpr.Name, parameterNamesAsString(funcSig), funcSig.ReturnType.FriendlyName(), funcSig.Description)),
Range: fe.expr.Range(),
Content: hoverContentForFunction(funcExpr.Name, funcSig),
Range: fe.expr.Range(),
}
}

Expand Down Expand Up @@ -297,3 +346,14 @@ func (fe functionExpr) matchingFunctions(prefix string, editRange hcl.Range) []l

return candidates
}

func hoverContentForFunction(name string, funcSig schema.FunctionSignature) lang.MarkupContent {
rawMd := fmt.Sprintf("```terraform\n%s(%s) %s\n```\n\n%s",
name, parameterNamesAsString(funcSig), funcSig.ReturnType.FriendlyName(), funcSig.Description)

if funcSig.Detail != "" {
rawMd += fmt.Sprintf("\n\n%s", funcSig.Detail)
}

return lang.Markdown(rawMd)
}
37 changes: 37 additions & 0 deletions decoder/expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package decoder

import (
"context"
"unicode"
"unicode/utf8"

"github.com/hashicorp/hcl-lang/lang"
Expand Down Expand Up @@ -253,13 +254,49 @@ func recoverLeftBytes(b []byte, pos hcl.Pos, f func(byteOffset int, r rune) bool
return []byte{}
}

// recoverRightBytes seeks right from given pos in given slice of bytes
// and recovers all bytes up until f matches, including that match.
// This allows recovery of incomplete configuration which is not
// present in the parsed AST during completion.
//
// Zero bytes is returned if no match was found.
func recoverRightBytes(b []byte, pos hcl.Pos, f func(byteOffset int, r rune) bool) []byte {
nextRune, size := utf8.DecodeRune(b[pos.Byte:])
offset := pos.Byte + size

// check for early match
if f(pos.Byte, nextRune) {
return b[pos.Byte:offset]
}

for offset < len(b) {
nextRune, size := utf8.DecodeRune(b[offset:])
if f(offset, nextRune) {
// record the matched offset
// and include the matched last rune
endByte := offset + size
return b[pos.Byte:endByte]
}
offset += size
}

return []byte{}
}

// isObjectItemTerminatingRune returns true if the given rune
// is considered a left terminating character for an item
// in hclsyntax.ObjectConsExpr.
func isObjectItemTerminatingRune(r rune) bool {
return r == '\n' || r == ',' || r == '{'
}

// isNamespacedFunctionNameRune returns true if the given run
// is a valid character of a namespaced function name.
// This includes letters, digits, dashes, underscores, and colons.
func isNamespacedFunctionNameRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == ':'
}

// rawObjectKey extracts raw key (as string) from KeyExpr of
// any hclsyntax.ObjectConsExpr along with the corresponding range
// and boolean indicating whether the extraction was successful.
Expand Down
42 changes: 42 additions & 0 deletions decoder/expression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,48 @@ func TestRecoverLeftBytes(t *testing.T) {
}
}

func TestRecoverRightBytes(t *testing.T) {
testCases := []struct {
b []byte
pos hcl.Pos
f func(int, rune) bool
expectedBytes []byte
}{
{
[]byte(`toot foobar`),
hcl.Pos{Line: 1, Column: 1, Byte: 0},
func(i int, r rune) bool {
return unicode.IsSpace(r)
},
[]byte(`toot `),
},
{
[]byte(`provider::local::direxists()`),
hcl.Pos{Line: 1, Column: 15, Byte: 14},
func(i int, r rune) bool {
return !isNamespacedFunctionNameRune(r) && r != '('
},
[]byte(`l::direxists()`),
},
{
[]byte(`hello world👋and other planets`),
hcl.Pos{Line: 1, Column: 7, Byte: 6},
func(i int, r rune) bool {
return r == '👋'
},
[]byte(`world👋`),
},
}
for i, tc := range testCases {
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
recoveredBytes := recoverRightBytes(tc.b, tc.pos, tc.f)
if !bytes.Equal(tc.expectedBytes, recoveredBytes) {
t.Fatalf("mismatch!\nexpected: %q\nrecovered: %q\n", string(tc.expectedBytes), string(recoveredBytes))
}
})
}
}

func TestRawObjectKey(t *testing.T) {
testCases := []struct {
cfg string
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.21.0
require (
github.com/google/go-cmp v0.6.0
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/hcl/v2 v2.19.1
github.com/hashicorp/hcl/v2 v2.20.1
github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5
github.com/zclconf/go-cty v1.14.2
github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b
Expand Down
7 changes: 2 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI=
github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc=
github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5 h1:shw+DWUaHIyW64Tv30ASCbC6QO6fLy+M5SJb5pJVEI4=
github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5/go.mod h1:nHPoxaBUc5CDAMIv0MNmn5PBjWbTs9BI/eh30/n0U6g=
Expand Down
20 changes: 20 additions & 0 deletions schema/signature.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type FunctionSignature struct {
// of the function.
Description string

Detail string

// ReturnType is the ctyjson representation of the function's
// return types based on supplying all parameters using
// dynamic types. Functions can have dynamic return types.
Expand All @@ -25,3 +27,21 @@ type FunctionSignature struct {
// parameter if it is supported.
VarParam *function.Parameter
}

func (fs *FunctionSignature) Copy() *FunctionSignature {
newFS := &FunctionSignature{
Description: fs.Description,
Detail: fs.Detail,
ReturnType: fs.ReturnType,
}

newFS.Params = make([]function.Parameter, len(fs.Params))
copy(newFS.Params, fs.Params)
ansgarm marked this conversation as resolved.
Show resolved Hide resolved

if fs.VarParam != nil {
vpCpy := *fs.VarParam
newFS.VarParam = &vpCpy
}

return newFS
}
Loading