Gated Method Invocations

“Joy isn’t a mood. Joy is a method.”

Bea Roubo

Not long ago, I wrote about public parameterless methods for Records. Now let’s take it a step further and let a whole host of other methods run wild in our templates. The parameterless methods for Records were added for the sake of symmetry. Now we’re going to expand the application with a truly new feature.

Before we delve deeper into the implementation of method calls, we need to address FreshMarker’s design principles. All objects that we can use in templates have one thing in common: there is an implementation of TemplateObject that handles the object’s usage. For data types such as String, Boolean, or Number, these are TemplateString, TemplateBoolean, and TemplateNumber; for List implementations, they are TemplateSequence; and for Map, Record, and Bean implementations, they are TemplateStringMap and TemplateEnumMap. These model classes not only wrap the objects but also define how the objects can be used. They define arithmetic operators, comparison operators, and the display format. Furthermore, they serve as anchors for the built-in implementations. Therefore, if a class cannot be wrapped by one of these model types, it cannot be used in a template.

For most TemplateObject instances, the behavior is precisely implemented. For example, with a Locale, you can query the country and language via TemplateLocal; with a TemplateString, you can perform uppercase conversion using a built-in function; and with a range on a TemplateString, you can even generate inverted substrings. With Beans and Records, however, the capabilities are somewhat limited. Since they are treated like Maps, you can only query the keys and values and perform a few other related operations.

Additional capabilities for dedicated Bean and Record classes cannot be implemented this way in FreshMarker. This is because once a class is recognized as a Bean, it is used as such. If you want special handling, you must create your own model type. That’s quite a bit of effort just to call an additional method on an instance.

Now, in this post, we’re rolling out the option to call on extra methods for Beans and Records, on top of the getter methods we’ve already got.

${calculator:: greatestCommonDivisor(number1, number2)}

In this example, calculator is a Bean that provides several useful methods, including one for calculating the greatest common divisor of two numbers. A method call begins with :: and is followed by the method name and its parameters. For the two numbers 43,560 and 18,150, this interpolation returns 3,630.

Anyone who has wondered why the title refers to “Gated Method Invocation” will recognize the limitations of these methods here. The class must be recognized as a Bean or Record, the method must be a public instance method, its parameters must correspond to model classes, and its return value must correspond to a model class. Therefore, not just any method can be used here; several hurdles must be cleared.

The syntax for these method calls is already buried in our CongoCC grammar, so we’re now digging it out.

MethodReference :
    <DOUBLE_COLON>
    <IDENTIFIER>
    [MethodInvoke]
;

MethodInvoke  :
  <OPEN_PAREN>[ArgsList]<CLOSE_PAREN>
;

The MethodInvoke in this CongoCC production is an old familiar. This replacement is already used for function calls. In this case, only an identifier may appear before the MethodInvoke as the function name.

These outputs are processed in InterpolationBuilder.

@Override
public TemplateMethodCall visit(MethodInvoke expression, Object input) {
  String name = ((TemplateVariable) ((TemplateObjectAndNode) input).templateObject()).name();
  List<TemplateObject> args = parseMethodInvokeArgs(expression);
  return new TemplateMethodCall(name, args.isEmpty() ? null : args);
}

@Override
public TemplateMethodReference visit(MethodReference expression, Object input) {
  String methodName = expression.get(1).toString();
  TemplateObject base = ((TemplateObjectAndNode) input).templateObject();
  List<TemplateObject> args = null;
  if (expression.size() > 2) {
    args = parseMethodInvokeArgs((MethodInvoke) expression.get(2));
  }
  return new TemplateMethodReference(base, methodName, args);
}

If the Visitor visits a MethodReference, we process a Function, and to create a TemplateMethodCall instance, we only need the name from the input and the parameter list from MethodInvoke. If we visit a MethodReference, we do not delegate the contained MethodReference via the Visitor; instead, we evaluate the parameter list directly and create a TemplateMethodReference instance.

So what does a TemplateMethodReference do in our template? It is a record that implements a TemplateExpression.

public record TemplateMethodReference(
  TemplateObject target,
  String methodName,
  List<TemplateObject> args
) implements TemplateExpression {

  @Override
  public TemplateObject evaluateToObject(ProcessContext context) {
    TemplateObject resolved = target.evaluateToObject(context);
    return switch (resolved) {
      case TemplateNull n -> n;
      case MethodCallable callable -> invoke(context, callable);
      default -> throw new WrongTypeException(
          ":: method call only supported on MethodCallable types, got: " 
          + resolved.getClass().getSimpleName());
    };
  }

  // ...
}

Like all implementations of TemplateExpression or TemplateObject, it has an evaluateToObject method that performs the evaluation. In the case of a TemplateMethodReference, this means that the method should be called on the current object. For this evaluation, only the TemplateNull and MethodCallable implementations are relevant. TemplateNull is passed through as in other TemplateExpression instances and may result in subsequent errors. Of greater interest is the MethodCallable interface, which is currently implemented only by the base class for Hashes. This interface is used to call the actual method within the invoke method.

private TemplateObject invoke(ProcessContext context, MethodCallable callable) {
  Class<?> type = callable.getMethodCallType();
  if (type == null) {
    throw new WrongTypeException(":: method call is not supported on inline hash literals");
  }
  Object[] rawArgs = evaluateArgs(context);
  Object result = context.getBeanMethodCallProvider().invoke(type, callable.getMethodCallInstance(), methodName, rawArgs);
  return context.mapObject(result);
}

The flow in the invoke method is quite simple. The callable reveals its type via getMethodCallType(). For Maps, the method returns null and an exception is thrown. But for Records and Beans, we get the correct type, and then we can call the method on the instance using getMethodCallInstance() and the parameters. Here, the call is delegated to a BeanMethodCallProvider, which we’ll omit for educational purposes. However, it is useful for storing MethodHandles for all our Beans and Records.

One limitation that hasn’t been mentioned yet—and which we hope will be resolved soon—concerns the parameters of the method call. For sequences, hashes, and other more complex model types, we cannot simply read the value required for the call from the wrapper; therefore, only primitive model types are supported for now.

Another weakness of this implementation is, of course, the use of MethodHandles and the subsequent check in the interpreter. As is so often the case, the interpreter can only check—when the method is called—whether the class in question defines that method and whether the parameters match. Things are quite different for our Type 2 compiler, but we’ll talk about that another time.

Leave a Comment