From 88a5167bf0cd6388310d716f7983ddbbe91156c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Basl=C3=A9?= Date: Mon, 27 Feb 2023 19:58:05 +0100 Subject: [PATCH] Add SpEL support for registered MethodHandles This commit adds support for MethodHandles in SpEL, using the same syntax as user-defined functions (which also covers reflective Methods). The most benefit is expected with handles that capture a static method with no arguments, or with fully bound handles (where all the arguments have been bound, including a target instance as first bound argument if necessary). Partially bound MethodHandle should also be supported. A best effort approach is taken to detect varargs as there is no API support to determine if an argument is a vararg or an explicit array, unlike with Method. Argument conversions are also applied. Finally, array repacking is not always necessary with varargs so it is only performed when the vararg is the sole argument to the invoked method. See gh-27099 Closes gh-30045 --- .../modules/ROOT/pages/core/expressions.adoc | 4 +- .../pages/core/expressions/language-ref.adoc | 2 +- .../expressions/language-ref/functions.adoc | 95 ++++++++++++++++++- .../spel/ast/FunctionReference.java | 86 ++++++++++++++++- .../spel/support/ReflectionHelper.java | 88 +++++++++++++++++ .../support/StandardEvaluationContext.java | 5 + .../spel/ExpressionLanguageScenarioTests.java | 39 ++++++++ .../spel/SpelDocumentationTests.java | 35 +++++++ .../expression/spel/TestScenarioCreator.java | 43 +++++++++ 9 files changed, 393 insertions(+), 4 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/core/expressions.adoc b/framework-docs/modules/ROOT/pages/core/expressions.adoc index a87addc06f54..cfde855e6d3c 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions.adoc @@ -47,7 +47,9 @@ The expression language supports the following functionality: * Inline maps * Ternary operator * Variables -* User-defined functions +* User-defined functions added to the context + * reflective invocation of `Method` + * various cases of `MethodHandle` * Collection projection * Collection selection * Templated expressions diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc index f920ac60470e..c69ffaf6bc1b 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref.adoc @@ -15,7 +15,7 @@ topics: * xref:core/expressions/language-ref/types.adoc[Types] * xref:core/expressions/language-ref/constructors.adoc[Constructors] * xref:core/expressions/language-ref/variables.adoc[Variables] -* xref:core/expressions/language-ref/functions.adoc[Functions] +* xref:core/expressions/language-ref/functions.adoc[User-Defined Functions] * xref:core/expressions/language-ref/bean-references.adoc[Bean References] * xref:core/expressions/language-ref/operator-ternary.adoc[Ternary Operator (If-Then-Else)] * xref:core/expressions/language-ref/operator-elvis.adoc[The Elvis Operator] diff --git a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc index 014d6e8b8c44..4621b2a19aaa 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/language-ref/functions.adoc @@ -3,7 +3,8 @@ You can extend SpEL by registering user-defined functions that can be called within the expression string. The function is registered through the `EvaluationContext`. The -following example shows how to register a user-defined function: +following example shows how to register a user-defined function to be invoked via reflection +(i.e. a `Method`): [tabs] ====== @@ -94,5 +95,97 @@ Kotlin:: ---- ====== +The use of `MethodHandle` is also supported. This enables potentially more efficient use +cases if the `MethodHandle` target and parameters have been fully bound prior to +registration, but partially bound handles are also supported. + +Consider the `String#formatted(String, Object...)` instance method, which produces a +message according to a template and a variable number of arguments. + +You can register and use the `formatted` method as a `MethodHandle`, as the following +example shows: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +---- + ExpressionParser parser = new SpelExpressionParser(); + EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)); + context.setVariable("message", mh); + + String message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')") + .getValue(context, String.class); + //returns "Simple message: " +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +---- + val parser = SpelExpressionParser() + val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() + + val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted", + MethodType.methodType(String::class.java, Array::class.java)) + context.setVariable("message", mh) + + val message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')") + .getValue(context, String::class.java) +---- +====== + +As hinted above, binding a `MethodHandle` and registering the bound `MethodHandle` is also +supported. This is likely to be more performant if both the target and all the arguments +are bound. In that case no arguments are necessary in the SpEL expression, as the +following example shows: + +[tabs] +====== +Java:: ++ +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +---- + ExpressionParser parser = new SpelExpressionParser(); + EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + + String template = "This is a %s message with %s words: <%s>"; + Object varargs = new Object[] { "prerecorded", 3, "Oh Hello World!", "ignored" }; + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)) + .bindTo(template) + .bindTo(varargs); //here we have to provide arguments in a single array binding + context.setVariable("message", mh); + + String message = parser.parseExpression("#message()") + .getValue(context, String.class); + //returns "This is a prerecorded message with 3 words: " +---- + +Kotlin:: ++ +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +---- + val parser = SpelExpressionParser() + val context = SimpleEvaluationContext.forReadOnlyDataBinding().build() + + val template = "This is a %s message with %s words: <%s>" + val varargs = arrayOf("prerecorded", 3, "Oh Hello World!", "ignored") + + val mh = MethodHandles.lookup().findVirtual(String::class.java, "formatted", + MethodType.methodType(String::class.java, Array::class.java)) + .bindTo(template) + .bindTo(varargs) //here we have to provide arguments in a single array binding + context.setVariable("message", mh) + + val message = parser.parseExpression("#message()") + .getValue(context, String::class.java) +---- +====== + diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java index 4ad5b0dda907..766ac2704bbd 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/FunctionReference.java @@ -16,6 +16,8 @@ package org.springframework.expression.spel.ast; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodType; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.StringJoiner; @@ -70,7 +72,17 @@ public TypedValue getValueInternal(ExpressionState state) throws EvaluationExcep if (value == TypedValue.NULL) { throw new SpelEvaluationException(getStartPosition(), SpelMessage.FUNCTION_NOT_DEFINED, this.name); } - if (!(value.getValue() instanceof Method function)) { + Object resolvedValue = value.getValue(); + if (resolvedValue instanceof MethodHandle methodHandle) { + try { + return executeFunctionBoundMethodHandle(state, methodHandle); + } + catch (SpelEvaluationException ex) { + ex.setPosition(getStartPosition()); + throw ex; + } + } + if (!(resolvedValue instanceof Method function)) { // Possibly a static Java method registered as a function throw new SpelEvaluationException( SpelMessage.FUNCTION_REFERENCE_CANNOT_BE_INVOKED, this.name, value.getClass()); @@ -138,6 +150,78 @@ private TypedValue executeFunctionJLRMethod(ExpressionState state, Method method } } + /** + * Execute a function represented as {@code java.lang.invoke.MethodHandle}. + * Method types that take no arguments (fully bound handles or static methods + * with no parameters) can use {@code #invoke()} which is the most efficient. + * Otherwise, {@code #invokeWithArguments)} is used. + * @param state the expression evaluation state + * @param methodHandle the method to invoke + * @return the return value of the invoked Java method + * @throws EvaluationException if there is any problem invoking the method + * @since 6.1.0 + */ + private TypedValue executeFunctionBoundMethodHandle(ExpressionState state, MethodHandle methodHandle) throws EvaluationException { + Object[] functionArgs = getArguments(state); + MethodType declaredParams = methodHandle.type(); + int spelParamCount = functionArgs.length; + int declaredParamCount = declaredParams.parameterCount(); + + boolean isSuspectedVarargs = declaredParams.lastParameterType().isArray(); + + if (spelParamCount < declaredParamCount || (spelParamCount > declaredParamCount + && !isSuspectedVarargs)) { + //incorrect number, including more arguments and not a vararg + throw new SpelEvaluationException(SpelMessage.INCORRECT_NUMBER_OF_ARGUMENTS_TO_FUNCTION, + functionArgs.length, declaredParamCount); + //perhaps a subset of arguments was provided but the MethodHandle wasn't bound? + } + + // simplest case: the MethodHandle is fully bound or represents a static method with no params: + if (declaredParamCount == 0) { + //note we consider MethodHandles not compilable + try { + return new TypedValue(methodHandle.invoke()); + } + catch (Throwable ex) { + throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL, + this.name, ex.getMessage()); + } + finally { + this.exitTypeDescriptor = null; + this.method = null; + } + } + + // more complex case, we need to look at conversion and vararg repacking + Integer varArgPosition = null; + if (isSuspectedVarargs) { + varArgPosition = declaredParamCount - 1; + } + TypeConverter converter = state.getEvaluationContext().getTypeConverter(); + boolean conversionOccurred = ReflectionHelper.convertAllMethodHandleArguments(converter, + functionArgs, methodHandle, varArgPosition); + + if (isSuspectedVarargs && declaredParamCount == 1) { + //we only repack the varargs if it is the ONLY argument + functionArgs = ReflectionHelper.setupArgumentsForVarargsInvocation( + methodHandle.type().parameterArray(), functionArgs); + } + + //note we consider MethodHandles not compilable + try { + return new TypedValue(methodHandle.invokeWithArguments(functionArgs)); + } + catch (Throwable ex) { + throw new SpelEvaluationException(getStartPosition(), ex, SpelMessage.EXCEPTION_DURING_FUNCTION_CALL, + this.name, ex.getMessage()); + } + finally { + this.exitTypeDescriptor = null; + this.method = null; + } + } + @Override public String toStringAST() { StringJoiner sj = new StringJoiner(",", "(", ")"); diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java index e9b62ec2adbb..2e44a02fce96 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/ReflectionHelper.java @@ -16,6 +16,8 @@ package org.springframework.expression.spel.support; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodType; import java.lang.reflect.Array; import java.lang.reflect.Executable; import java.lang.reflect.Method; @@ -23,6 +25,7 @@ import java.util.Optional; import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; import org.springframework.core.convert.TypeDescriptor; import org.springframework.expression.EvaluationException; import org.springframework.expression.TypeConverter; @@ -330,6 +333,91 @@ else if (!sourceType.equals(targetType.getElementTypeDescriptor())) { return conversionOccurred; } + /** + * Takes an input set of argument values and converts them to the types specified as the + * required parameter types. The arguments are converted 'in-place' in the input array. + * @param converter the type converter to use for attempting conversions + * @param arguments the actual arguments that need conversion + * @param methodHandle the target MethodHandle + * @param varargsPosition the known position of the varargs argument, if any + * ({@code null} if not varargs) + * @return {@code true} if some kind of conversion occurred on an argument + * @throws EvaluationException if a problem occurs during conversion + * @since 6.1.0 + */ + public static boolean convertAllMethodHandleArguments(TypeConverter converter, Object[] arguments, + MethodHandle methodHandle, @Nullable Integer varargsPosition) throws EvaluationException { + boolean conversionOccurred = false; + final MethodType methodHandleArgumentTypes = methodHandle.type(); + if (varargsPosition == null) { + for (int i = 0; i < arguments.length; i++) { + Class argumentClass = methodHandleArgumentTypes.parameterType(i); + ResolvableType resolvableType = ResolvableType.forClass(argumentClass); + TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null); + + Object argument = arguments[i]; + arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType); + conversionOccurred |= (argument != arguments[i]); + } + } + else { + // Convert everything up to the varargs position + for (int i = 0; i < varargsPosition; i++) { + Class argumentClass = methodHandleArgumentTypes.parameterType(i); + ResolvableType resolvableType = ResolvableType.forClass(argumentClass); + TypeDescriptor targetType = new TypeDescriptor(resolvableType, argumentClass, null); + + Object argument = arguments[i]; + arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), targetType); + conversionOccurred |= (argument != arguments[i]); + } + + final Class varArgClass = methodHandleArgumentTypes.lastParameterType().getComponentType(); + ResolvableType varArgResolvableType = ResolvableType.forClass(varArgClass); + TypeDescriptor varArgContentType = new TypeDescriptor(varArgResolvableType, varArgClass, null); + + // If the target is varargs and there is just one more argument, then convert it here. + if (varargsPosition == arguments.length - 1) { + Object argument = arguments[varargsPosition]; + TypeDescriptor sourceType = TypeDescriptor.forObject(argument); + if (argument == null) { + // Perform the equivalent of GenericConversionService.convertNullSource() for a single argument. + if (varArgContentType.getElementTypeDescriptor().getObjectType() == Optional.class) { + arguments[varargsPosition] = Optional.empty(); + conversionOccurred = true; + } + } + // If the argument type is equal to the varargs element type, there is no need to + // convert it or wrap it in an array. For example, using StringToArrayConverter to + // convert a String containing a comma would result in the String being split and + // repackaged in an array when it should be used as-is. + else if (!sourceType.equals(varArgContentType.getElementTypeDescriptor())) { + arguments[varargsPosition] = converter.convertValue(argument, sourceType, varArgContentType); + } + // Possible outcomes of the above if-else block: + // 1) the input argument was null, and nothing was done. + // 2) the input argument was null; the varargs element type is Optional; and the argument was converted to Optional.empty(). + // 3) the input argument was correct type but not wrapped in an array, and nothing was done. + // 4) the input argument was already compatible (i.e., array of valid type), and nothing was done. + // 5) the input argument was the wrong type and got converted and wrapped in an array. + if (argument != arguments[varargsPosition] && + !isFirstEntryInArray(argument, arguments[varargsPosition])) { + conversionOccurred = true; // case 5 + } + } + // Otherwise, convert remaining arguments to the varargs element type. + else { + Assert.state(varArgContentType != null, "No element type"); + for (int i = varargsPosition; i < arguments.length; i++) { + Object argument = arguments[i]; + arguments[i] = converter.convertValue(argument, TypeDescriptor.forObject(argument), varArgContentType); + conversionOccurred |= (argument != arguments[i]); + } + } + } + return conversionOccurred; + } + /** * Check if the supplied value is the first entry in the array represented by the possibleArray value. * @param value the value to check for in the array diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java index 7cde54692e0d..4940861a369a 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/StandardEvaluationContext.java @@ -16,6 +16,7 @@ package org.springframework.expression.spel.support; +import java.lang.invoke.MethodHandle; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; @@ -251,6 +252,10 @@ public void registerFunction(String name, Method method) { this.variables.put(name, method); } + public void registerFunction(String name, MethodHandle methodHandle) { + this.variables.put(name, methodHandle); + } + @Override @Nullable public Object lookupVariable(String name) { diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java index aa6bf033f52e..3fbc0af024fd 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/ExpressionLanguageScenarioTests.java @@ -188,6 +188,45 @@ public void testScenario_RegisteringJavaMethodsAsFunctionsAndCallingThem() throw } } + /** + * Scenario: looking up your own MethodHandles and calling them from the expression + */ + @Test + public void testScenario_RegisteringJavaMethodsAsMethodHandlesAndCallingThem() throws SecurityException, NoSuchMethodException { + try { + // Create a parser + SpelExpressionParser parser = new SpelExpressionParser(); + //this.context is already populated with all relevant MethodHandle examples + + Expression expr = parser.parseRaw("#message('Message with %s words: <%s>', 2, 'Hello World', 'ignored')"); + Object value = expr.getValue(this.context); + assertThat(value).isEqualTo("Message with 2 words: "); + + expr = parser.parseRaw("#messageTemplate('bound', 2, 'Hello World', 'ignored')"); + value = expr.getValue(this.context); + assertThat(value).isEqualTo("This is a bound message with 2 words: "); + + expr = parser.parseRaw("#messageBound()"); + value = expr.getValue(this.context); + assertThat(value).isEqualTo("This is a prerecorded message with 3 words: "); + + Expression staticExpr = parser.parseRaw("#messageStatic('Message with %s words: <%s>', 2, 'Hello World', 'ignored')"); + Object staticValue = staticExpr.getValue(this.context); + assertThat(staticValue).isEqualTo("Message with 2 words: "); + + staticExpr = parser.parseRaw("#messageStaticTemplate('bound', 2, 'Hello World', 'ignored')"); + staticValue = staticExpr.getValue(this.context); + assertThat(staticValue).isEqualTo("This is a bound message with 2 words: "); + + staticExpr = parser.parseRaw("#messageStaticBound()"); + staticValue = staticExpr.getValue(this.context); + assertThat(staticValue).isEqualTo("This is a prerecorded message with 3 words: "); + } + catch (EvaluationException | ParseException ex) { + throw new AssertionError(ex.getMessage(), ex); + } + } + /** * Scenario: add a property resolver that will get called in the resolver chain, this one only supports reading. */ diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java index 3e9d3bc92a95..1ba488ccab0b 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelDocumentationTests.java @@ -16,6 +16,9 @@ package org.springframework.expression.spel; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; @@ -415,6 +418,38 @@ void functions() throws Exception { assertThat(helloWorldReversed).isEqualTo("dlrow olleh"); } + @Test + void methodHandlesNotBound() throws Throwable { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)); + context.setVariable("message", mh); + + String message = parser.parseExpression("#message('Simple message: <%s>', 'Hello World', 'ignored')") + .getValue(context, String.class); + assertThat(message).isEqualTo("Simple message: "); + } + + @Test + void methodHandlesFullyBound() throws Throwable { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + String template = "This is a %s message with %s words: <%s>"; + Object varargs = new Object[] { "prerecorded", 3, "Oh Hello World!", "ignored" }; + MethodHandle mh = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)) + .bindTo(template) + .bindTo(varargs); //here we have to provide arguments in a single array binding + context.setVariable("message", mh); + + String message = parser.parseExpression("#message()") + .getValue(context, String.class); + assertThat(message).isEqualTo("This is a prerecorded message with 3 words: "); + } + // 7.5.10 @Test diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java b/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java index ebeebcf7e0ce..2d626013f076 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/TestScenarioCreator.java @@ -16,6 +16,9 @@ package org.springframework.expression.spel; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.util.Arrays; import java.util.GregorianCalendar; @@ -37,6 +40,12 @@ public static StandardEvaluationContext getTestEvaluationContext() { setupRootContextObject(testContext); populateVariables(testContext); populateFunctions(testContext); + try { + populateMethodHandles(testContext); + } + catch (NoSuchMethodException | IllegalAccessException e) { + throw new RuntimeException(e); + } return testContext; } @@ -62,6 +71,36 @@ private static void populateFunctions(StandardEvaluationContext testContext) { } } + /** + * Register some Java {@code MethodHandle} as well known functions that can be called from an expression. + * @param testContext the test evaluation context + */ + private static void populateMethodHandles(StandardEvaluationContext testContext) throws NoSuchMethodException, IllegalAccessException { + // #message(template, args...) + MethodHandle message = MethodHandles.lookup().findVirtual(String.class, "formatted", + MethodType.methodType(String.class, Object[].class)); + testContext.registerFunction("message", message); + // #messageTemplate(args...) + MethodHandle messageWithParameters = message.bindTo("This is a %s message with %s words: <%s>"); + testContext.registerFunction("messageTemplate", messageWithParameters); + // #messageTemplateBound() + MethodHandle messageBound = messageWithParameters + .bindTo(new Object[] { "prerecorded", 3, "Oh Hello World", "ignored"}); + testContext.registerFunction("messageBound", messageBound); + + //#messageStatic(template, args...) + MethodHandle messageStatic = MethodHandles.lookup().findStatic(TestScenarioCreator.class, + "message", MethodType.methodType(String.class, String.class, String[].class)); + testContext.registerFunction("messageStatic", messageStatic); + //#messageStaticTemplate(args...) + MethodHandle messageStaticPartiallyBound = messageStatic.bindTo("This is a %s message with %s words: <%s>"); + testContext.registerFunction("messageStaticTemplate", messageStaticPartiallyBound); + //#messageStaticBound() + MethodHandle messageStaticFullyBound = messageStaticPartiallyBound + .bindTo(new String[] { "prerecorded", "3", "Oh Hello World", "ignored"}); + testContext.registerFunction("messageStaticBound", messageStaticFullyBound); + } + /** * Register some variables that can be referenced from the tests * @param testContext the test evaluation context @@ -117,4 +156,8 @@ public static String varargsFunction2(int i, String... strings) { return String.valueOf(i) + "-" + Arrays.toString(strings); } + public static String message(String template, String... args) { + return template.formatted((Object[]) args); + } + }