Skip to content

Commit

Permalink
Support for user defined functions (#10465)
Browse files Browse the repository at this point in the history
See
https://github.com/Azure/bicep/blob/ant/exp/func/src/Bicep.Core.Samples/Files/Functions_LF/main.bicep
for a syntax example.

Notes/limitations:
* UDFs in JSON require declaring the return type, so I've modified the
spec to require the user to declare the return type.
* User-defined types in UDF params & outputs are unsupported.
* UDFs are currently quite limited - they can't call UDFs, or access
outer scoped variables.
* This is currently behind an experimental feature flag
("userDefinedFunctions")

Closes #447
Closes #9239

###### Microsoft Reviewers: [Open in
CodeFlow](https://portal.fabricbot.ms/api/codeflow?pullrequest=https://github.com/Azure/bicep/pull/10465)
  • Loading branch information
anthony-c-martin authored May 1, 2023
1 parent adeab53 commit 23ebbef
Show file tree
Hide file tree
Showing 117 changed files with 3,439 additions and 230 deletions.
14 changes: 11 additions & 3 deletions docs/grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ statement ->
resourceDecl |
moduleDecl |
outputDecl |
functionDecl |
NL
targetScopeDecl -> "targetScope" "=" expression
Expand Down Expand Up @@ -42,6 +43,8 @@ outputDecl ->
decorator* "output" IDENTIFIER(name) "resource" interpString(type) "=" expression NL
NL -> ("\n" | "\r")+
functionDecl -> decorator* "func" IDENTIFIER(name) typedLambdaExpression NL
decorator -> "@" decoratorExpression NL
disableNextLineDiagnosticsDirective-> #disable-next-line diagnosticCode1 diagnosticCode2 diagnosticCode3 NL
Expand Down Expand Up @@ -106,13 +109,18 @@ primaryExpression ->
decoratorExpression -> functionCall | memberExpression "." functionCall
functionCall -> IDENTIFIER "(" argumentList? ")"
argumentList -> expression ("," expression)*
functionCall -> IDENTIFIER "(" argumentList? ")"
parenthesizedExpression -> "(" expression ")"
lambdaExpression -> ( "(" argumentList? ")" | IDENTIFIER ) "=>" expression
localVariable -> IDENTIFIER
variableBlock -> "(" ( localVariable ("," localVariable)* )? ")"
lambdaExpression -> ( variableBlock | localVariable ) "=>" expression
typedLocalVariable -> IDENTIFIER primaryTypeExpression
typedVariableBlock -> "(" ( typedLocalVariable ("," typedLocalVariable)* )? ")"
typedLambdaExpression -> typedVariableBlock primaryTypeExpression "=>" expression
ifCondition -> "if" parenthesizedExpression object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ public void PrintProgram_PrintTwice_ReturnsConsistentResults(DataSet dataSet)
var newSyntaxErrorMessages = newSyntaxErrors.Select(d => d.Message);

// Diagnostic messages should remain the same after formatting.
syntaxErrors.Should().HaveSameCount(newSyntaxErrorMessages);
newSyntaxErrorMessages.Should().BeEquivalentTo(syntaxErrroMessages);

// Normalize formatting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ s is ResourceSymbol ||
s is ModuleSymbol ||
s is OutputSymbol ||
s is FunctionSymbol ||
s is DeclaredFunctionSymbol ||
s is ImportedNamespaceSymbol ||
s is BuiltInNamespaceSymbol ||
s is LocalVariableSymbol);
Expand All @@ -147,6 +148,7 @@ s is ResourceSymbol ||
s is ModuleSymbol ||
s is OutputSymbol ||
s is FunctionSymbol ||
s is DeclaredFunctionSymbol ||
s is ImportedNamespaceSymbol ||
s is BuiltInNamespaceSymbol ||
s is LocalVariableSymbol);
Expand Down
129 changes: 129 additions & 0 deletions src/Bicep.Core.IntegrationTests/UserDefinedFunctionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics.CodeAnalysis;
using Bicep.Core.Diagnostics;
using Bicep.Core.UnitTests;
using Bicep.Core.UnitTests.Assertions;
using Bicep.Core.UnitTests.Utils;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Bicep.Core.IntegrationTests;

[TestClass]
public class UserDefinedFunctionTests
{
private ServiceBuilder Services => new ServiceBuilder()
.WithFeatureOverrides(new(TestContext, UserDefinedFunctionsEnabled: true));

[NotNull] public TestContext? TestContext { get; set; }

[TestMethod]
public void User_defined_functions_basic_case()
{
var result = CompilationHelper.Compile(Services, @"
func buildUrl(https bool, hostname string, path string) string => '${https ? 'https' : 'http'}://${hostname}${empty(path) ? '' : '/${path}'}'
output foo string = buildUrl(true, 'google.com', 'search')
");

result.Should().NotHaveAnyDiagnostics();
var evaluated = TemplateEvaluator.Evaluate(result.Template);

evaluated.Should().HaveValueAtPath("$.outputs['foo'].value", "https://google.com/search");
}

[TestMethod]
public void Outer_scope_symbolic_references_are_blocked()
{
var result = CompilationHelper.Compile(Services, @"
param foo string
var bar = 'abc'
func getBaz() string => 'baz'
func testFunc(baz string) string => '${foo}-${bar}-${baz}-${getBaz()}'
");

result.ExcludingLinterDiagnostics().Should().HaveDiagnostics(new [] {
("BCP057", DiagnosticLevel.Error, """The name "foo" does not exist in the current context."""),
("BCP057", DiagnosticLevel.Error, """The name "bar" does not exist in the current context."""),
("BCP057", DiagnosticLevel.Error, """The name "getBaz" does not exist in the current context."""),
});
}

[TestMethod]
public void Functions_can_have_descriptions_applied()
{
var result = CompilationHelper.Compile(Services, @"
@description('Returns foo')
func returnFoo() string => 'foo'
output outputFoo string = returnFoo()
");

result.Should().NotHaveAnyDiagnostics();
var evaluated = TemplateEvaluator.Evaluate(result.Template);

evaluated.Should().HaveValueAtPath("$.outputs['outputFoo'].value", "foo");
}

[TestMethod]
public void User_defined_functions_cannot_reference_each_other()
{
var result = CompilationHelper.Compile(Services, @"
func getAbc() string => 'abc'
func getAbcDef() string => '${getAbc()}def'
");

result.Should().HaveDiagnostics(new [] {
("BCP057", DiagnosticLevel.Error, "The name \"getAbc\" does not exist in the current context."),
});
}

[TestMethod]
public void User_defined_functions_unsupported_custom_types()
{
var services = new ServiceBuilder().WithFeatureOverrides(new(UserDefinedTypesEnabled: true, UserDefinedFunctionsEnabled: true));

var result = CompilationHelper.Compile(services, @"
func getAOrB(aOrB ('a' | 'b')) bool => (aOrB == 'a')
");

result.Should().HaveDiagnostics(new [] {
("BCP342", DiagnosticLevel.Error, "User-defined types are not supported in user-defined function parameters or outputs."),
});

result = CompilationHelper.Compile(services, @"
func getAOrB(aOrB bool) ('a' | 'b') => aOrB ? 'a' : 'b'
");

result.Should().HaveDiagnostics(new [] {
("BCP342", DiagnosticLevel.Error, "User-defined types are not supported in user-defined function parameters or outputs."),
});
}

[TestMethod]
public void User_defined_functions_unsupported_runtime_functions()
{
var result = CompilationHelper.Compile(Services, @"
func useRuntimeFunction() string => reference('foo').bar
");

result.Should().HaveDiagnostics(new [] {
("BCP341", DiagnosticLevel.Error, "This expression is being used inside a function declaration, which requires a value that can be calculated at the start of the deployment."),
});
}

[TestMethod]
public void User_defined_functions_requires_experimental_feature_enabled()
{
var services = new ServiceBuilder().WithFeatureOverrides(new());

var result = CompilationHelper.Compile(services, @"
func useRuntimeFunction() string => 'test'
");

result.Should().HaveDiagnostics(new [] {
("BCP343", DiagnosticLevel.Error, "Using a func declaration statement requires enabling EXPERIMENTAL feature \"UserDefinedFunctions\"."),
});
}
}
4 changes: 4 additions & 0 deletions src/Bicep.Core.Samples/DataSets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ public static class DataSets

public static DataSet Empty => CreateDataSet();

public static DataSet Functions_LF => CreateDataSet();

public static DataSet InvalidCycles_CRLF => CreateDataSet();

public static DataSet InvalidDisableNextLineDiagnosticsDirective_CRLF => CreateDataSet();

public static DataSet InvalidExpressions_LF => CreateDataSet();

public static DataSet InvalidFunctions_LF => CreateDataSet();

public static DataSet InvalidMetadata_CRLF => CreateDataSet();

public static DataSet InvalidOutputs_CRLF => CreateDataSet();
Expand Down
5 changes: 5 additions & 0 deletions src/Bicep.Core.Samples/Files/Functions_LF/bicepconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"experimentalFeaturesEnabled": {
"userDefinedFunctions": true
}
}
20 changes: 20 additions & 0 deletions src/Bicep.Core.Samples/Files/Functions_LF/main.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
func buildUrl(https bool, hostname string, path string) string => '${https ? 'https' : 'http'}://${hostname}${empty(path) ? '' : '/${path}'}'

output foo string = buildUrl(true, 'google.com', 'search')

func sayHello(name string) string => 'Hi ${name}!'

output hellos array = map(['Evie', 'Casper'], name => sayHello(name))

func objReturnType(name string) object => {
hello: 'Hi ${name}!'
}

func arrayReturnType(name string) array => [
name
]

func asdf(name string) array => [
'asdf'
name
]
21 changes: 21 additions & 0 deletions src/Bicep.Core.Samples/Files/Functions_LF/main.diagnostics.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
func buildUrl(https bool, hostname string, path string) string => '${https ? 'https' : 'http'}://${hostname}${empty(path) ? '' : '/${path}'}'

output foo string = buildUrl(true, 'google.com', 'search')

func sayHello(name string) string => 'Hi ${name}!'

output hellos array = map(['Evie', 'Casper'], name => sayHello(name))

func objReturnType(name string) object => {
hello: 'Hi ${name}!'
}

func arrayReturnType(name string) array => [
name
]

func asdf(name string) array => [
'asdf'
name
]

20 changes: 20 additions & 0 deletions src/Bicep.Core.Samples/Files/Functions_LF/main.formatted.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
func buildUrl(https bool, hostname string, path string) string => '${https ? 'https' : 'http'}://${hostname}${empty(path) ? '' : '/${path}'}'

output foo string = buildUrl(true, 'google.com', 'search')

func sayHello(name string) string => 'Hi ${name}!'

output hellos array = map([ 'Evie', 'Casper' ], name => sayHello(name))

func objReturnType(name string) object => {
hello: 'Hi ${name}!'
}

func arrayReturnType(name string) array => [
name
]

func asdf(name string) array => [
'asdf'
name
]
69 changes: 69 additions & 0 deletions src/Bicep.Core.Samples/Files/Functions_LF/main.ir.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
func buildUrl(https bool, hostname string, path string) string => '${https ? 'https' : 'http'}://${hostname}${empty(path) ? '' : '/${path}'}'
//@[000:503) ProgramExpression
//@[000:141) ├─DeclaredFunctionExpression { Name = buildUrl }
//@[013:141) | └─LambdaExpression
//@[066:141) | └─InterpolatedStringExpression
//@[069:093) | ├─TernaryExpression
//@[069:074) | | ├─LambdaVariableReferenceExpression { Variable = https }
//@[077:084) | | ├─StringLiteralExpression { Value = https }
//@[087:093) | | └─StringLiteralExpression { Value = http }
//@[099:107) | ├─LambdaVariableReferenceExpression { Variable = hostname }
//@[110:139) | └─TernaryExpression
//@[110:121) | ├─FunctionCallExpression { Name = empty }
//@[116:120) | | └─LambdaVariableReferenceExpression { Variable = path }
//@[124:126) | ├─StringLiteralExpression { Value = }
//@[129:139) | └─InterpolatedStringExpression
//@[133:137) | └─LambdaVariableReferenceExpression { Variable = path }

output foo string = buildUrl(true, 'google.com', 'search')
//@[000:058) ├─DeclaredOutputExpression { Name = foo }
//@[020:058) | └─UserDefinedFunctionCallExpression { Name = buildUrl }
//@[029:033) | ├─BooleanLiteralExpression { Value = True }
//@[035:047) | ├─StringLiteralExpression { Value = google.com }
//@[049:057) | └─StringLiteralExpression { Value = search }

func sayHello(name string) string => 'Hi ${name}!'
//@[000:050) ├─DeclaredFunctionExpression { Name = sayHello }
//@[013:050) | └─LambdaExpression
//@[037:050) | └─InterpolatedStringExpression
//@[043:047) | └─LambdaVariableReferenceExpression { Variable = name }

output hellos array = map(['Evie', 'Casper'], name => sayHello(name))
//@[000:069) └─DeclaredOutputExpression { Name = hellos }
//@[022:069) └─FunctionCallExpression { Name = map }
//@[026:044) ├─ArrayExpression
//@[027:033) | ├─StringLiteralExpression { Value = Evie }
//@[035:043) | └─StringLiteralExpression { Value = Casper }
//@[046:068) └─LambdaExpression
//@[054:068) └─UserDefinedFunctionCallExpression { Name = sayHello }
//@[063:067) └─LambdaVariableReferenceExpression { Variable = name }

func objReturnType(name string) object => {
//@[000:068) ├─DeclaredFunctionExpression { Name = objReturnType }
//@[018:068) | └─LambdaExpression
//@[042:068) | └─ObjectExpression
hello: 'Hi ${name}!'
//@[002:022) | └─ObjectPropertyExpression
//@[002:007) | ├─StringLiteralExpression { Value = hello }
//@[009:022) | └─InterpolatedStringExpression
//@[015:019) | └─LambdaVariableReferenceExpression { Variable = name }
}

func arrayReturnType(name string) array => [
//@[000:053) ├─DeclaredFunctionExpression { Name = arrayReturnType }
//@[020:053) | └─LambdaExpression
//@[043:053) | └─ArrayExpression
name
//@[002:006) | └─LambdaVariableReferenceExpression { Variable = name }
]

func asdf(name string) array => [
//@[000:051) ├─DeclaredFunctionExpression { Name = asdf }
//@[009:051) | └─LambdaExpression
//@[032:051) | └─ArrayExpression
'asdf'
//@[002:008) | ├─StringLiteralExpression { Value = asdf }
name
//@[002:006) | └─LambdaVariableReferenceExpression { Variable = name }
]

Loading

0 comments on commit 23ebbef

Please sign in to comment.