Skip to content

Commit

Permalink
feat: support namespaced functions (#372)
Browse files Browse the repository at this point in the history
* feat: add Detail to FunctionSignature and implement Copy()

* feat: deeper copy for function params

* fix: handle nil case for function parameter cloning

* chore: remove outdated todo comment

* feat: include Detail in hover data for functions

* chore: update hashicorp/hcl to unreleased 1e1a6b85 version

* chore: simplify copy

* tests: add example for a namespaced function to tests

* fix: apply review suggestions

* feat: recover namespaced functions for completions

* Bump hcl to 2.20.1

* fix: allow any namespaced functions besides provider:: prefixed ones

* Add test for `TestRecoverRightBytes`

* Add link to follow-up issue

---------

Co-authored-by: Daniel Banck <daniel@dbanck.de>
  • Loading branch information
ansgarm and dbanck committed Mar 26, 2024
1 parent 10351d8 commit 49d7378
Show file tree
Hide file tree
Showing 8 changed files with 686 additions and 9 deletions.
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.4
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)

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

return newFS
}

0 comments on commit 49d7378

Please sign in to comment.