diff --git a/java/src/com/google/template/soy/jbcsrc/ExpressionCompiler.java b/java/src/com/google/template/soy/jbcsrc/ExpressionCompiler.java index 0451f9eaba..bfb531b818 100644 --- a/java/src/com/google/template/soy/jbcsrc/ExpressionCompiler.java +++ b/java/src/com/google/template/soy/jbcsrc/ExpressionCompiler.java @@ -119,6 +119,7 @@ import com.google.template.soy.jbcsrc.restricted.SoyRuntimeType; import com.google.template.soy.jbcsrc.restricted.Statement; import com.google.template.soy.jbcsrc.restricted.TypeInfo; +import com.google.template.soy.jbcsrc.runtime.JbcSrcExternRuntime; import com.google.template.soy.jbcsrc.shared.ClassLoaderFallbackCallFactory; import com.google.template.soy.jbcsrc.shared.ExtraConstantBootstraps; import com.google.template.soy.jbcsrc.shared.Names; @@ -151,11 +152,13 @@ import com.google.template.soy.types.MessageType; import com.google.template.soy.types.MutableListType; import com.google.template.soy.types.MutableMapType; +import com.google.template.soy.types.RecordType; import com.google.template.soy.types.SetType; import com.google.template.soy.types.SoyProtoEnumType; import com.google.template.soy.types.SoyProtoType; import com.google.template.soy.types.SoyType; import com.google.template.soy.types.SoyTypes; +import com.google.template.soy.types.UnknownType; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.util.ArrayList; @@ -1764,6 +1767,48 @@ var record = (RecordLiteralNode) node.getChild(1); baseExpr.checkedCast(BytecodeUtils.TEMPLATE_VALUE_TYPE), recordLiteralAsParamStoreForBind(record))); } + case INVOKE_FUNCTION_PROPERTY: + { + // We are compiling a dynamic function property invocation (e.g., `$myRecord.fn()`). + // 1. First, we extract the function object from the record at runtime. + // 2. We resolve the property to a SoyValue (which will be a JbcSrcFunctionValue). + // 3. We invoke the generic .call() interface on that function value. + Expression fieldProvider = + MethodRefs.RUNTIME_GET_FIELD_PROVIDER.invoke( + baseExpr.box(), constantRecordProperty(node.getMethodName().identifier())); + SoyExpression fieldExpr = + SoyExpression.forSoyValue( + UnknownType.getInstance(), detacher.resolveSoyValueProvider(fieldProvider)); + + SoyRuntimeType soyReturnType = ExternCompiler.getRuntimeType(node.getType()); + + FunctionType functionType = null; + if (node.getBaseExprChild().getType() instanceof RecordType recordType) { + SoyType propertyType = recordType.getMemberType(node.getMethodName().identifier()); + if (propertyType instanceof FunctionType fType) { + functionType = fType; + } + } + + Expression obj = + fieldExpr + .checkedCast(FUNCTION_VALUE_TYPE) + .invoke( + MethodRefs.FUNCTION_WITH_RENDER_CONTEXT, parameters.getRenderContext()) + .invoke( + MethodRefs.FUNCTION_CALL, + adaptFunctionArgs(functionType, node.getParams())); + + if (BytecodeUtils.isPrimitive(soyReturnType.runtimeType())) { + obj = BytecodeUtils.unboxJavaPrimitive(soyReturnType.runtimeType(), obj); + } else if (soyReturnType.runtimeType().equals(BytecodeUtils.SOY_VALUE_TYPE)) { + obj = JbcSrcExternRuntime.CONVERT_OBJECT_TO_SOY_VALUE.invoke(obj); + } else { + obj = obj.checkedCast(soyReturnType.runtimeType()); + } + + return SoyExpression.forRuntimeType(soyReturnType, obj); + } } } else if (function instanceof SoySourceFunctionMethod) { SoySourceFunctionMethod sourceMethod = (SoySourceFunctionMethod) function; @@ -2043,6 +2088,8 @@ SoyExpression visitPluginFunction(FunctionNode node) { // relying on MethodHandle.invokeExact. if (BytecodeUtils.isPrimitive(soyReturnType.runtimeType())) { obj = BytecodeUtils.unboxJavaPrimitive(soyReturnType.runtimeType(), obj); + } else if (soyReturnType.runtimeType().equals(BytecodeUtils.SOY_VALUE_TYPE)) { + obj = JbcSrcExternRuntime.CONVERT_OBJECT_TO_SOY_VALUE.invoke(obj); } else { obj = obj.checkedCast(soyReturnType.runtimeType()); } @@ -2064,11 +2111,18 @@ SoyExpression visitPluginFunction(FunctionNode node) { .checkedSoyCast(node.getType())); } - private Expression adaptFunctionArgs(FunctionType type, List args) { + // The type may be null if the function was stored in an 'any' or '?' type (UNKNOWN). + // In that case, we don't know the exact parameter types, so we pass UnknownType + // to adaptExternArg, effectively disabling type-specific adaptation. + private Expression adaptFunctionArgs(@Nullable FunctionType type, List args) { List adaptedArgs = new ArrayList<>(args.size()); for (int i = 0; i < args.size(); i++) { ExprNode param = args.get(i); - SoyType paramType = type.getParameters().get(i).getType(); + SoyType paramType = UnknownType.getInstance(); + if (type != null) { + int index = Math.min(i, type.getParameters().size() - 1); + paramType = type.getParameters().get(index).getType(); + } Expression adapted = adaptExternArg(visit(param), paramType); if (BytecodeUtils.isPrimitive(adapted.resultType())) { adapted = BytecodeUtils.boxJavaPrimitive(adapted); diff --git a/java/src/com/google/template/soy/jbcsrc/shared/JbcSrcFunctionValue.java b/java/src/com/google/template/soy/jbcsrc/shared/JbcSrcFunctionValue.java index 9155c8f675..0197e28b9a 100644 --- a/java/src/com/google/template/soy/jbcsrc/shared/JbcSrcFunctionValue.java +++ b/java/src/com/google/template/soy/jbcsrc/shared/JbcSrcFunctionValue.java @@ -23,6 +23,8 @@ import com.google.common.collect.Iterables; import com.google.template.soy.base.internal.FunctionalInterfaceUtil; import com.google.template.soy.data.LoggingAdvisingAppendable; +import com.google.template.soy.data.SoyList; +import com.google.template.soy.data.SoyMap; import com.google.template.soy.data.SoyValue; import com.google.template.soy.data.SoyValueConverter; import java.lang.invoke.MethodHandle; @@ -31,6 +33,8 @@ import java.lang.invoke.MethodType; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Map; /** Runtime type for function pointers. */ @AutoValue @@ -92,7 +96,56 @@ public Object call(ImmutableList args) throws Throwable { // Ignore extra arguments. args = args.subList(0, paramCount); } - return getHandle().invokeWithArguments(args); + + Object[] adaptedArgs = new Object[args.size()]; + for (int i = 0; i < args.size(); i++) { + Object arg = args.get(i); + Class paramType = getHandle().type().parameterType(i); + // The incoming args are boxed SoyValues, but the underlying Java method might + // expect raw primitives or standard Java types. We unbox them if needed to + // match the exact signature of the target method before invoking it. + adaptedArgs[i] = unboxSoyValueIfNecessary(arg, paramType); + } + return getHandle().invokeWithArguments(adaptedArgs); + } + + /** + * Unboxes a generic SoyValue argument into a specific Java type required by the underlying Java + * method signature (e.g., extracting the primitive `long` from an `IntegerData` box). If the + * method simply expects a SoyValue or Object, it returns the argument unchanged. + */ + private static Object unboxSoyValueIfNecessary(Object arg, Class expectedType) { + if (!(arg instanceof SoyValue soyValue)) { + return arg; + } + if (expectedType == long.class || expectedType == Long.class) { + return soyValue.longValue(); + } + if (expectedType == int.class || expectedType == Integer.class) { + return soyValue.integerValue(); + } + if (expectedType == double.class || expectedType == Double.class) { + return soyValue.floatValue(); + } + if (expectedType == float.class || expectedType == Float.class) { + return (float) soyValue.floatValue(); + } + if (expectedType == boolean.class || expectedType == Boolean.class) { + return soyValue.booleanValue(); + } + if (expectedType == String.class) { + return soyValue.stringValue(); + } + if (expectedType == Number.class) { + return soyValue.numberValue(); + } + if (List.class.isAssignableFrom(expectedType) && soyValue instanceof SoyList soyList) { + return soyList.asJavaList(); + } + if (Map.class.isAssignableFrom(expectedType) && soyValue instanceof SoyMap soyMap) { + return soyMap.asJavaMap(); + } + return arg; } public T asInstance(Class iface) { diff --git a/java/src/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitor.java b/java/src/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitor.java index 99adf91412..0ed77287c5 100644 --- a/java/src/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitor.java +++ b/java/src/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitor.java @@ -806,10 +806,18 @@ private NullSafeAccumulator genCodeForMethodCall( return base.transform( nullSafe, (baseExpr) -> genCodeForBind(baseExpr, visit(methodCallNode.getParam(0)), baseType)); + case INVOKE_FUNCTION_PROPERTY: + // Compiles a dynamic function property invocation in JS (e.g. `$myRecord.fn()`). + // We compile this down to a standard JavaScript property access and call: + // `base.fn(...)`. FieldAccess.call handles exactly this. + return base.dotAccess( + FieldAccess.call( + methodCallNode.getMethodName().identifier(), + methodCallNode.getParams().stream().map(this::visit).collect(toImmutableList())), + nullSafe); } throw new AssertionError(builtinMethod); - } else if (soyMethod instanceof SoySourceFunctionMethod) { - SoySourceFunctionMethod sourceMethod = (SoySourceFunctionMethod) soyMethod; + } else if (soyMethod instanceof SoySourceFunctionMethod sourceMethod) { return base.functionCall( nullSafe, diff --git a/java/src/com/google/template/soy/passes/FunctionPropertyMethod.java b/java/src/com/google/template/soy/passes/FunctionPropertyMethod.java new file mode 100644 index 0000000000..2723401bbd --- /dev/null +++ b/java/src/com/google/template/soy/passes/FunctionPropertyMethod.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.template.soy.passes; + +import com.google.template.soy.shared.restricted.SoyMethod; +import com.google.template.soy.types.FunctionType; +import com.google.template.soy.types.SoyType; +import javax.annotation.Nullable; + +/** + * A specialized {@link SoyMethod} used during method resolution to represent invoking a + * function-typed property on a record. + * + *

This method does not leak to backend code generation; it is used solely within {@link + * ResolveExpressionTypesPass} to route method calls to function invocations. + */ +final class FunctionPropertyMethod implements SoyMethod { + + @Nullable final FunctionType functionType; + + FunctionPropertyMethod(@Nullable SoyType type) { + this.functionType = type instanceof FunctionType fnType ? fnType : null; + } + + @Override + public int getNumArgs() { + return functionType == null ? -1 : functionType.getParameters().size(); + } + + @Override + public boolean acceptsArgCount(int count) { + if (functionType == null) { + return true; + } + return functionType.isVarArgs() ? count >= getNumArgs() - 1 : count == getNumArgs(); + } +} diff --git a/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java b/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java index bff56d8758..e934018609 100644 --- a/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java +++ b/java/src/com/google/template/soy/passes/ResolveExpressionTypesPass.java @@ -2007,7 +2007,12 @@ private void finishFieldAccessNode(FieldAccessNode node, boolean nullSafe) { node.setType(fieldImpl.getReturnType()); node.setSoyMethod(fieldImpl); } else { - node.setType(getFieldType(baseType, node.getFieldName(), node.getAccessSourceLocation())); + node.setType( + getFieldType( + baseType, + node.getFieldName(), + node.getAccessSourceLocation(), + /* reportErrors= */ true)); } tryApplySubstitution(node); } @@ -2046,6 +2051,7 @@ private void finishMethodCallNode(MethodCallNode node, boolean nullSafe) { } SoyType baseType = node.getBaseType(nullSafe).getEffectiveType(); + SoyMethod method = resolveMethodFromBaseType(node, baseType); if (method == null) { @@ -2053,6 +2059,32 @@ private void finishMethodCallNode(MethodCallNode node, boolean nullSafe) { return; } + // If we resolved this method to a FunctionPropertyMethod, it means the user is invoking + // a function stored as a field on a record. We need to enforce type checking on the arguments + // passed to the function property, just as we do for regular function calls. + if (method instanceof FunctionPropertyMethod functionPropertyMethod) { + FunctionType functionType = functionPropertyMethod.functionType; + if (functionType != null) { + for (int i = 0; i < node.numParams(); i++) { + SoyType expectedType = + functionType + .getParameters() + .get(Math.min(i, functionType.getParameters().size() - 1)) + .getType(); + if (functionType.isVarArgs() && i >= functionType.getParameters().size() - 1) { + if (expectedType instanceof ListType listType) { + expectedType = listType.getElementType(); + } + } + maybeCoerceType(node.getParam(i), expectedType); + } + } + node.setSoyMethod(BuiltinMethod.INVOKE_FUNCTION_PROPERTY); + node.setType( + functionType != null ? functionType.getReturnType() : UnknownType.getInstance()); + return; + } + checkSpread(node); node.setSoyMethod(method); @@ -2210,8 +2242,35 @@ private SoyMethod resolveMethodFromBaseType(MethodCallNode node, SoyType baseTyp List argTypes = node.getParams().stream().map(ExprNode::getType).collect(toList()); // This contains all methods that match name and base type. - ImmutableList matchNameAndType = - methodRegistry.matchForNameAndBase(methodName, baseType); + List matchNameAndType = + new ArrayList<>(methodRegistry.matchForNameAndBase(methodName, baseType)); + + // In Soy, UNKNOWN (`?`) is the dynamic type where type checking is disabled. This means we + // must allow any method to be called dynamically, falling back to a FunctionPropertyMethod. + // + // Note that unlike TypeScript, Soy's ANY (`any`) is the safe top type. Therefore, it will + // NOT fall back here, and the compiler will correctly throw a compilation error when a method + // invocation is made on it. + if (baseType.getKind() == SoyType.Kind.UNKNOWN) { + if (matchNameAndType.isEmpty()) { + return new FunctionPropertyMethod(UnknownType.getInstance()); + } + } + + // Support invoking function properties directly: e.g. `$myRecord.fn()` + // The AST parses this syntax initially as a MethodCallNode (trying to call a built-in method + // on the record). If `fn` is actually a field on the record that stores a function (or + // ?/any), we intercept it here and resolve it as a FunctionPropertyMethod so it gets compiled + // as a dynamic function call instead of throwing a "method not found" error. + if (baseType instanceof RecordType) { + SoyType fieldType = getFieldType(baseType, methodName, srcLoc, /* reportErrors= */ false); + if (fieldType != null + && SoyTypes.isKindOrUnionOfKinds( + fieldType, + ImmutableSet.of(SoyType.Kind.FUNCTION, SoyType.Kind.UNKNOWN, SoyType.Kind.ANY))) { + matchNameAndType.add(new FunctionPropertyMethod(fieldType)); + } + } // Subset of previous that also matches arg count. List andMatchArgCount = @@ -2324,6 +2383,16 @@ private List getParamTypes(SoyMethod method, SoyType baseType) { return ImmutableList.of(arg); } return sourceMethod.getParamTypes(); + } else if (method instanceof FunctionPropertyMethod functionPropertyMethod) { + // For dynamic function properties (e.g., $myRecord.fn()), extract the parameter types + // from its FunctionType so they can be used for argument validation. + FunctionType functionType = functionPropertyMethod.functionType; + if (functionType == null) { + return ImmutableList.of(); + } + return functionType.getParameters().stream() + .map(FunctionType.Parameter::getType) + .collect(toImmutableList()); } return ImmutableList.of(); } @@ -2340,6 +2409,42 @@ private boolean appliesToArgs(SoyMethod method, SoyType baseType, List } } return true; + } else if (method instanceof FunctionPropertyMethod functionPropertyMethod) { + // Verify that the provided arguments match the expected parameter types of the + // function property, making sure to handle varargs correctly if the function has them. + FunctionType functionType = functionPropertyMethod.functionType; + if (functionType == null) { + // If the function type is not explicitly defined (e.g., it's a dynamic `any` type), + // we cannot validate arguments, so we conservatively assume they are valid. + return true; + } + + ImmutableList params = functionType.getParameters(); + for (int i = 0; i < argTypes.size(); i++) { + // Fetch the expected type for the current argument. If we have more arguments + // than parameters, we clamp to the last parameter's type (used for varargs). + SoyType expectedType = params.get(Math.min(i, params.size() - 1)).getType(); + + // If this is a varargs function and we are evaluating the vararg parameter itself + // or any subsequent arguments... + if (functionType.isVarArgs() && i >= params.size() - 1) { + // The vararg parameter is typically typed as a List (e.g., list), + // but the individual arguments passed in will be of the element type (e.g., string). + if (expectedType instanceof ListType listType) { + expectedType = listType.getElementType(); + } + } + + // Verify if the argument's type can be assigned to the expected parameter type. + // We skip validation if the expected type is implicit, or if the argument type + // is completely unknown. We use loose assignability for broader compatibility. + if (expectedType != ImplicitType.getInstance() + && !expectedType.isAssignableFromLoose(argTypes.get(i)) + && argTypes.get(i) != UnknownType.getInstance()) { + return false; + } + } + return true; } else { return true; } @@ -2814,7 +2919,24 @@ protected void visitFunctionNode(FunctionNode node) { errorReporter.report(node.getFunctionNameLocation(), INCORRECT_ARG_STYLE); node.setSoyFunction(FunctionNode.UNRESOLVED); } else { - VarDefn defn = ((VarRefNode) node.getNameExpr()).getDefnDecl(); + // Attempt to resolve the definition of the function being called. + // If the function is called via a simple variable reference (e.g., `$myFunc()`), + // we can extract its definition declaration. + VarDefn defn = + node.getNameExpr() instanceof VarRefNode varRefNode + ? varRefNode.getDefnDecl() + : null; + + if (defn == null) { + // If there is no explicit variable definition (for example, if the function + // is being called from an expression or property like `$myRecord.fn()`), we + // treat this as a dynamic function pointer invocation. + node.setSoyFunction(FunctionNode.FUNCTION_POINTER); + // We can still infer the return type of the call from the function's type signature. + node.setType(SoyTypes.getFunctionReturnType(nameExprType)); + return; + } + List externTypes; if (defn.kind() == VarDefn.Kind.SYMBOL && ((SymbolVar) defn).isImported()) { @@ -3238,25 +3360,31 @@ private void requireNodeType(ExprNode node) { * * @param baseType The base type. * @param fieldName The name of the field. - * @param sourceLocation The source location of the expression - * @return The type of the field. + * @param sourceLocation The source location of the expression. + * @param reportErrors Whether to report errors for missing or invalid fields. + * @return The type of the field, or {@code null} if it doesn't exist and {@code reportErrors} + * is false. */ + @Nullable private SoyType getFieldType( - SoyType baseType, String fieldName, SourceLocation sourceLocation) { + SoyType baseType, String fieldName, SourceLocation sourceLocation, boolean reportErrors) { SoyType effectiveType = baseType.getEffectiveType(); - switch (effectiveType.getKind()) { - case UNKNOWN -> { - // If we don't know anything about the base type, then make no assumptions - // about the field type. - return UnknownType.getInstance(); - } + return switch (effectiveType.getKind()) { + case UNKNOWN -> + // If we don't know anything about the base type, then make no assumptions + // about the field type. Any access is permitted, and the result is unknown. + UnknownType.getInstance(); case RECORD -> { + // Records have strongly-typed fields. We look up the exact member by name. RecordType recordType = (RecordType) effectiveType; SoyType fieldType = recordType.getMemberType(fieldName); if (fieldType != null) { - return fieldType; - } else { + // Found the field directly on the record. + yield fieldType; + } else if (reportErrors) { + // The field was missing. We provide a helpful "Did you mean ...?" error + // if there's a similarly named field to guide the user. String extraErrorMessage = SoyErrors.getDidYouMeanMessage(recordType.getMemberNames(), fieldName); errorReporter.report( @@ -3265,48 +3393,81 @@ private SoyType getFieldType( fieldName, baseType, extraErrorMessage); - return UnknownType.getInstance(); + // Return unknown type to prevent cascading type errors. + yield UnknownType.getInstance(); } + // If we aren't reporting errors (e.g. probing for existence), just yield null. + yield null; } case LEGACY_OBJECT_MAP -> { - errorReporter.report(sourceLocation, DOT_ACCESS_NOT_SUPPORTED_CONSIDER_RECORD, baseType); - return UnknownType.getInstance(); + // Dot access (e.g., map.field) is deliberately unsupported on legacy object maps + // to encourage migration to records or using bracket access (e.g., map['field']). + if (reportErrors) { + errorReporter.report( + sourceLocation, DOT_ACCESS_NOT_SUPPORTED_CONSIDER_RECORD, baseType); + yield UnknownType.getInstance(); + } + yield null; } case UNION -> { - // If it's a union, then do the field type calculation for each member of - // the union and combine the result. + // If the base type is a union (e.g. `RecordA | RecordB`), we must compute the field + // type for *every* member of the union. The field must exist on all non-nullish members. ErrorReporter.Checkpoint cp = errorReporter.checkpoint(); UnionType unionType = (UnionType) effectiveType; List fieldTypes = new ArrayList<>(unionType.getMembers().size()); + for (SoyType unionMember : unionType.getMembers()) { + // Skip nullish types. // TODO:(b/246982549): Remove this if-statement, as is this means you can freely // dereference nullish types without the compiler complaining. if (SoyTypes.isNullOrUndefined(unionMember)) { continue; } - SoyType fieldType = getFieldType(unionMember, fieldName, sourceLocation); - // If this member's field type resolved to an error, bail out to avoid spamming - // the user with multiple error messages for the same line. - if (errorReporter.errorsSince(cp)) { - return fieldType; + + // Recursively get the field type for this specific union member. + SoyType fieldType = getFieldType(unionMember, fieldName, sourceLocation, reportErrors); + + // If this member's field type couldn't be resolved (and we are failing silently), + // bail out entirely. + if (fieldType == null) { + yield null; // Silent failure + } + + // If checking this member produced a new error, yield the error type and stop + // to avoid spamming the user with multiple errors for the same dot access. + if (reportErrors && errorReporter.errorsSince(cp)) { + yield fieldType; } + + // Collect the successfully resolved field type for this member. fieldTypes.add(fieldType); } - return computeLowestCommonType(typeRegistry, fieldTypes); - } - case TEMPLATE_TYPE, PROTO_TYPE, PROTO_EXTENSION -> { - // May not be erased if other errors are present. - return UnknownType.getInstance(); + if (fieldTypes.isEmpty()) { + yield null; + } + + // The final type is the lowest common denominator of the field types + // from all the union members. + yield computeLowestCommonType(typeRegistry, fieldTypes); } + case TEMPLATE_TYPE, PROTO_TYPE, PROTO_EXTENSION -> + // These types do not support direct dot access for fields. + // (e.g. you can't do myProto.field directly in Soy without a method call). + reportErrors ? UnknownType.getInstance() : null; + default -> { - emitDefaultFieldNotFoundError(baseType, fieldName, sourceLocation); - return UnknownType.getInstance(); + // For all other types (primitives, lists, etc.), dot access is invalid. + if (reportErrors) { + emitDefaultFieldNotFoundError(baseType, fieldName, sourceLocation); + yield UnknownType.getInstance(); + } + yield null; } - } + }; } private void emitDefaultFieldNotFoundError( diff --git a/java/src/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitor.java b/java/src/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitor.java index e44afe42cc..cfe3006f67 100644 --- a/java/src/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitor.java +++ b/java/src/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitor.java @@ -895,6 +895,19 @@ private String genCodeForMethodCall(MethodCallNode methodCallNode, PyExpr contai SOY_PY_SRC_METHOD_NOT_FOUND, methodCallNode.getMethodName()); return ".ERROR"; + case INVOKE_FUNCTION_PROPERTY: + // Compiles a dynamic function property invocation in Python (e.g. `$myRecord.fn()`). + // We extract the field access (e.g. `base.get('fn')`) and wrap it in a + // PyFunctionExprBuilder to generate the Python function call syntax + // `base.get('fn')(...)`. + PyFunctionExprBuilder builder = + new PyFunctionExprBuilder( + genCodeForLiteralKeyAccess( + containerExpr, methodCallNode.getMethodName().identifier())); + for (ExprNode param : methodCallNode.getParams()) { + builder.addArg(visit(param)); + } + return builder.asPyExpr().getText(); } } else if (method instanceof SoySourceFunctionMethod) { SoySourceFunction function = ((SoySourceFunctionMethod) method).getImpl(); diff --git a/java/src/com/google/template/soy/shared/internal/BuiltinMethod.java b/java/src/com/google/template/soy/shared/internal/BuiltinMethod.java index 2aadb310a8..971a757879 100644 --- a/java/src/com/google/template/soy/shared/internal/BuiltinMethod.java +++ b/java/src/com/google/template/soy/shared/internal/BuiltinMethod.java @@ -408,6 +408,35 @@ public SoyType getReturnType( soyTypeRegistry, errorReporter.bind(param.getSourceLocation())); } + }, + + /** + * Represents the dynamic invocation of a function stored in a record property (e.g., + * `$myRecord.fn()`). This is a special BuiltinMethod that is never matched normally; it is + * exclusively injected by the ResolveExpressionTypesPass when it intercepts a method call that + * targets a function-typed record field. + */ + INVOKE_FUNCTION_PROPERTY("", -1) { + + @Override + public boolean appliesToBase(SoyType baseType) { + return false; // Handled dynamically in ResolveExpressionTypesPass + } + + @Override + public boolean appliesTo(String methodName, SoyType baseType) { + return false; // Handled dynamically + } + + @Override + public SoyType getReturnType( + String methodName, + SoyType baseType, + List params, + SoyTypeRegistry soyTypeRegistry, + ErrorReporter errorReporter) { + return UnknownType.getInstance(); // Resolved dynamically in ResolveExpressionTypesPass + } }; private static final SoyErrorKind GET_EXTENSION_BAD_ARG = @@ -493,6 +522,7 @@ public static String getProtoFieldNameFromMethodCall(MethodCallNode node) { case MAP_GET: case FUNCTION_BIND: case BIND: + case INVOKE_FUNCTION_PROPERTY: break; } throw new AssertionError("not a proto getter: " + node.getSoyMethod()); @@ -648,6 +678,7 @@ public List getProtoDependencyTypes(MethodCallNode methodNode case MAP_GET: case FUNCTION_BIND: case BIND: + case INVOKE_FUNCTION_PROPERTY: return ImmutableList.of(); } throw new AssertionError(this); diff --git a/java/src/com/google/template/soy/sharedpasses/render/EvalVisitor.java b/java/src/com/google/template/soy/sharedpasses/render/EvalVisitor.java index 6f93d1f948..27b6aa4168 100644 --- a/java/src/com/google/template/soy/sharedpasses/render/EvalVisitor.java +++ b/java/src/com/google/template/soy/sharedpasses/render/EvalVisitor.java @@ -746,6 +746,19 @@ private SoyValue visitMethodCallNode(MethodCallNode methodNode, SoyValue base) { ParamStore params = ParamStore.fromRecord((SoyRecord) visit(methodNode.getParam(0))); return TemplateValue.createWithBoundParameters( template.getTemplateName(), ParamStore.merge(template.getBoundParameters(), params)); + case INVOKE_FUNCTION_PROPERTY: + SoyRecord record = (SoyRecord) base; + TofuFunctionValue tofuFunction = + (TofuFunctionValue) + record.getField(RecordProperty.get(methodNode.getMethodName().identifier())); + return visitExtern( + tofuFunction.getImpl(), + tofuFunction.getBoundArgs(), + visitAllTofu(methodNode.getParams()), + methodNode.getType(), + methodNode.getSourceLocation(), + false) + .soyValue(); } } else if (method instanceof SoySourceFunctionMethod) { SoySourceFunctionMethod sourceMethod = (SoySourceFunctionMethod) method; diff --git a/java/tests/com/google/template/soy/jbcsrc/BytecodeCompilerTest.java b/java/tests/com/google/template/soy/jbcsrc/BytecodeCompilerTest.java index 00dc351901..21c48e8019 100644 --- a/java/tests/com/google/template/soy/jbcsrc/BytecodeCompilerTest.java +++ b/java/tests/com/google/template/soy/jbcsrc/BytecodeCompilerTest.java @@ -780,6 +780,21 @@ public void testParam_headerDocParam() { .rendersAs("4", ImmutableMap.of("foo", 3)); } + @Test + public void testFunctionPropertyCall() { + assertThatFile( + "{namespace ns}", + "{extern MathAbs: (a: int) => int}", + " {javaimpl class='java.lang.Math' method='abs' params='int' return='int' /}", + "{/extern}", + "", + "{template foo}", + " {let $myRecord: record(fn: MathAbs) /}", + " {$myRecord.fn(-5)}", + "{/template}") + .rendersAs("5"); + } + @Test public void testInjectParam() { assertThatTemplateBody("{@inject foo : int }", "{$foo + 1}") @@ -931,7 +946,7 @@ public void factoryReturnsSameInstanceEachTime() throws Exception { @Test public void testBasicFunctionality_privateTemplate() { - // make sure you can't access factories for priate templates + // make sure you can't access factories for private templates CompiledTemplates templates = TemplateTester.compileFile( "{namespace ns}{template foo visibility=\"private\"}hello world{/template}"); diff --git a/java/tests/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitorTest.java b/java/tests/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitorTest.java index 4ebdfe500c..1f5161455a 100644 --- a/java/tests/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitorTest.java +++ b/java/tests/com/google/template/soy/jssrc/internal/TranslateExprNodeVisitorTest.java @@ -185,4 +185,10 @@ public void tesUnknownJsGlobal() { assertThatSoyExpr("unknownJsGlobal('foo.Bar')") .generatesCode("/** @suppress {missingRequire} */", "const $tmp = foo.Bar;", "$tmp;"); } + + @Test + public void testInvokeFunctionProperty() { + assertThatSoyExpr(expr("$r.fn(1, 2)").withParam("r", "[fn: (a: any, b: any) => any]")) + .generatesCode("opt_data.r.fn(1, 2);"); + } } diff --git a/java/tests/com/google/template/soy/passes/ResolveExpressionTypesPassTest.java b/java/tests/com/google/template/soy/passes/ResolveExpressionTypesPassTest.java index 45dee71bfc..9a1841eed3 100644 --- a/java/tests/com/google/template/soy/passes/ResolveExpressionTypesPassTest.java +++ b/java/tests/com/google/template/soy/passes/ResolveExpressionTypesPassTest.java @@ -251,6 +251,35 @@ public void testRecordTypes() { "{assertType('string', $pa.b)}"); } + @Test + public void testInvokeFunctionProperty() { + assertTypes("{@param pa: [f: (p: any) => int]}", "{assertType('int', $pa.f(1))}"); + } + + @Test + public void testInvokeFunctionPropertyWithUnknownAndAny() { + assertTypes( + "{@param pa: ?}", + "{@param pc: [f: ?]}", + "{@param pd: [f: any]}", + "{assertType('?', $pa.f(1))}", + "{assertType('?', $pc.f(1))}", + "{assertType('?', $pd.f(1))}"); + } + + @Test + public void testInvokeFunctionPropertyError() { + assertResolveExpressionTypesFails( + "Method 'f' does not exist on type 'any'.", + constructFileSource("{@param pa: any}", "{$pa.f(1)}")); + assertResolveExpressionTypesFails( + "Method 'f' called with parameter types (string) but expected (int).", + constructFileSource("{@param pa: [f: (p: int) => int]}", "{$pa.f('a')}")); + assertResolveExpressionTypesFails( + "Method 'f' called with 2 parameter(s) but expected 1.", + constructFileSource("{@param pa: [f: (p: int) => int]}", "{$pa.f(1, 2)}")); + } + @Test public void testDataRefTypesWithUnknown() { // Test that data with the 'unknown' type is allowed to function as a map or list. diff --git a/java/tests/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitorTest.java b/java/tests/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitorTest.java index 5d3599f5ee..98769ad520 100644 --- a/java/tests/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitorTest.java +++ b/java/tests/com/google/template/soy/pysrc/internal/TranslateToPyExprVisitorTest.java @@ -152,4 +152,17 @@ public void testDefaultParamAccess() { assertThatSoyExpr("{@param p:= 18}\n" + " {$p}\n") .compilesTo(new PyExpr("sanitize.escape_html(data.get('p', 18))", Integer.MAX_VALUE)); } + + @Test + public void testInvokeFunctionProperty() { + assertThatSoyExpr( + """ + {@param r: [fn: (a: any, b: any) => any]} + {$r.fn(1, 2)} + """) + .compilesTo( + new PyExpr( + "sanitize.escape_html(runtime.check_not_null(data.get('r')).get('fn')(###, 2))", + Integer.MAX_VALUE)); + } }