Skip to content

Commit

Permalink
Merge pull request hashicorp#639 from hashicorp/f-functions-namespaced
Browse files Browse the repository at this point in the history
Namespaced functions
  • Loading branch information
jbardin authored Nov 9, 2023
2 parents 341ffa4 + 916ac48 commit c964a71
Show file tree
Hide file tree
Showing 11 changed files with 846 additions and 614 deletions.
62 changes: 62 additions & 0 deletions hclsyntax/expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package hclsyntax
import (
"fmt"
"sort"
"strings"
"sync"

"github.com/hashicorp/hcl/v2"
Expand Down Expand Up @@ -251,6 +252,67 @@ func (e *FunctionCallExpr) Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnosti
}
}

// For historical reasons, we represent namespaced function names
// as strings with :: separating the names. If this was an attempt
// to call a namespaced function then we'll try to distinguish
// between an invalid namespace or an invalid name within a valid
// namespace in order to give the user better feedback about what
// is wrong.
//
// The parser guarantees that a function name will always
// be a series of valid identifiers separated by "::" with no
// other content, so we can be relatively unforgiving in our processing
// here.
if sepIdx := strings.LastIndex(e.Name, "::"); sepIdx != -1 {
namespace := e.Name[:sepIdx+2]
name := e.Name[sepIdx+2:]

avail := make([]string, 0, len(ctx.Functions))
for availName := range ctx.Functions {
if strings.HasPrefix(availName, namespace) {
avail = append(avail, availName)
}
}

if len(avail) == 0 {
// TODO: Maybe use nameSuggestion for the other available
// namespaces? But that'd require us to go scan the function
// table again, so we'll wait to see if it's really warranted.
// For now, we're assuming people are more likely to misremember
// the function names than the namespaces, because in many
// applications there will be relatively few namespaces compared
// to the number of distinct functions.
return cty.DynamicVal, hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Call to unknown function",
Detail: fmt.Sprintf("There are no functions in namespace %q.", namespace),
Subject: &e.NameRange,
Context: e.Range().Ptr(),
Expression: e,
EvalContext: ctx,
},
}
} else {
suggestion := nameSuggestion(name, avail)
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %s%s?", namespace, suggestion)
}

return cty.DynamicVal, hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Call to unknown function",
Detail: fmt.Sprintf("There is no function named %q in namespace %s.%s", name, namespace, suggestion),
Subject: &e.NameRange,
Context: e.Range().Ptr(),
Expression: e,
EvalContext: ctx,
},
}
}
}

avail := make([]string, 0, len(ctx.Functions))
for name := range ctx.Functions {
avail = append(avail, name)
Expand Down
56 changes: 55 additions & 1 deletion hclsyntax/expression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,56 @@ upper(
cty.DynamicVal,
0,
},
{
`foo::upper("foo")`,
&hcl.EvalContext{
Functions: map[string]function.Function{
"foo::upper": stdlib.UpperFunc,
},
},
cty.StringVal("FOO"),
0,
},
{
`foo :: upper("foo")`, // spaces are non-idomatic, but valid
&hcl.EvalContext{
Functions: map[string]function.Function{
"foo::upper": stdlib.UpperFunc,
},
},
cty.StringVal("FOO"),
0,
},
{
`::upper("foo")`, // :: is still not a valid identifier
&hcl.EvalContext{
Functions: map[string]function.Function{
"::upper": stdlib.UpperFunc,
},
},
cty.DynamicVal,
1,
},
{
`double::::upper("foo")`, // missing name after ::
&hcl.EvalContext{
Functions: map[string]function.Function{
"double::::upper": stdlib.UpperFunc,
},
},
cty.NilVal,
1,
},
{
`missing::("foo")`, // missing name after ::
&hcl.EvalContext{
Functions: map[string]function.Function{
"missing::": stdlib.UpperFunc,
},
},
cty.NilVal,
1,
},
{
`misbehave()`,
&hcl.EvalContext{
Expand Down Expand Up @@ -2174,8 +2224,12 @@ EOT
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
expr, parseDiags := ParseExpression([]byte(test.input), "", hcl.Pos{Line: 1, Column: 1, Byte: 0})
var got cty.Value
var valDiags hcl.Diagnostics

got, valDiags := expr.Value(test.ctx)
if expr != nil {
got, valDiags = expr.Value(test.ctx)
}

diagCount := len(parseDiags) + len(valDiags)

Expand Down
2 changes: 1 addition & 1 deletion hclsyntax/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ package hclsyntax
//go:generate gofmt -w scan_tokens.go
//go:generate ragel -Z scan_string_lit.rl
//go:generate gofmt -w scan_string_lit.go
//go:generate stringer -type TokenType -output token_type_string.go
//go:generate go run golang.org/x/tools/cmd/stringer -type TokenType -output token_type_string.go
53 changes: 47 additions & 6 deletions hclsyntax/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -999,7 +999,7 @@ func (p *parser) parseExpressionTerm() (Expression, hcl.Diagnostics) {
case TokenIdent:
tok := p.Read() // eat identifier token

if p.Peek().Type == TokenOParen {
if p.Peek().Type == TokenOParen || p.Peek().Type == TokenDoubleColon {
return p.finishParsingFunctionCall(tok)
}

Expand Down Expand Up @@ -1145,16 +1145,57 @@ func (p *parser) numberLitValue(tok Token) (cty.Value, hcl.Diagnostics) {

// finishParsingFunctionCall parses a function call assuming that the function
// name was already read, and so the peeker should be pointing at the opening
// parenthesis after the name.
// parenthesis after the name, or at the double-colon after the initial
// function scope name.
func (p *parser) finishParsingFunctionCall(name Token) (Expression, hcl.Diagnostics) {
var diags hcl.Diagnostics

openTok := p.Read()
if openTok.Type != TokenOParen {
if openTok.Type != TokenOParen && openTok.Type != TokenDoubleColon {
// should never happen if callers behave
panic("finishParsingFunctionCall called with non-parenthesis as next token")
panic("finishParsingFunctionCall called with unsupported next token")
}

nameStr := string(name.Bytes)
for openTok.Type == TokenDoubleColon {
nextName := p.Read()
if nextName.Type != TokenIdent {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing function name",
Detail: "Function scope resolution symbol :: must be followed by a function name in this scope.",
Subject: &nextName.Range,
Context: hcl.RangeBetween(name.Range, nextName.Range).Ptr(),
})
p.recoverOver(TokenOParen)
return nil, diags
}

// Initial versions of HCLv2 didn't support function namespaces, and
// so for backward compatibility we just treat namespaced functions
// as weird names with "::" separators in them, saved as a string
// to keep the API unchanged. FunctionCallExpr also has some special
// handling of names containing :: when referring to a function that
// doesn't exist in EvalContext, to return better error messages
// when namespaces are used incorrectly.
nameStr = nameStr + "::" + string(nextName.Bytes)

openTok = p.Read()
}

if openTok.Type != TokenOParen {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing open parenthesis",
Detail: "Function selector must be followed by an open parenthesis to begin the function call.",
Subject: &openTok.Range,
Context: hcl.RangeBetween(name.Range, openTok.Range).Ptr(),
})
p.recoverOver(TokenOParen)
return nil, diags
}

var args []Expression
var diags hcl.Diagnostics
var expandFinal bool
var closeTok Token

Expand Down Expand Up @@ -1245,7 +1286,7 @@ Token:
p.PopIncludeNewlines()

return &FunctionCallExpr{
Name: string(name.Bytes),
Name: nameStr,
Args: args,

ExpandFinal: expandFinal,
Expand Down
Loading

0 comments on commit c964a71

Please sign in to comment.