Skip to content

Commit

Permalink
hclsyntax: Initial work on namespaced functions
Browse files Browse the repository at this point in the history
This introduces a new syntax which allows function names to have namespace
prefixes, with the different name parts separated by a double-colon "::"
as is common in various other C-derived languages which need to
distinguish between scope resolution and attribute/field traversal.

Because HCL has separate namespaces for functions and variables, we need
to use different punctuation for each to avoid creating parsing ambiguity
that could be resolved only with infinite lookahead.

We cannot retroactively change the representation of function names to be
a slice of names without breaking the existing API, and so we instead
adopt a convention of packing the multi-part names into single strings
which the parser guarantees will always be a series of valid identifiers
separated by the literal "::" sequence. That means that applications will
make namespaced functions available in the EvalContext by naming them in
a way that matches this convention.

This is still a subtle compatibility break for any implementation of the
syntax-agnostic HCL API against another syntax, because it may now
encounter function names in the function table that are not entirely
valid identifiers. However, that's okay in practice because a calling
application is always in full control of both which syntaxes it supports
and which functions it places in the function table, and so an application
using some other syntax can simply avoid using namespaced functions until
that syntax is updated to understand the new convention.

This initial commit only includes the basic functionality and does not yet
update the specification or specification test suite. It also has only
minimal unit tests of the parser and evaluator. Before finalizing this
in a release we would need to complete that work to make sure everything
is consistent and that we have sufficient regression tests for this new
capability.
  • Loading branch information
apparentlymart authored and jbardin committed Oct 24, 2023
1 parent d23a20a commit 83c95d2
Show file tree
Hide file tree
Showing 8 changed files with 744 additions and 611 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
20 changes: 20 additions & 0 deletions hclsyntax/expression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,26 @@ 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,
},
{
`misbehave()`,
&hcl.EvalContext{
Expand Down
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

0 comments on commit 83c95d2

Please sign in to comment.