diff --git a/src/main/java/eu/europa/ted/efx/exceptions/TranslatorConfigurationException.java b/src/main/java/eu/europa/ted/efx/exceptions/TranslatorConfigurationException.java index e3e52283..7e20ba72 100644 --- a/src/main/java/eu/europa/ted/efx/exceptions/TranslatorConfigurationException.java +++ b/src/main/java/eu/europa/ted/efx/exceptions/TranslatorConfigurationException.java @@ -111,6 +111,10 @@ public static TranslatorConfigurationException missingTypeMapping(Class type, return new TranslatorConfigurationException(ErrorCode.MISSING_TYPE_MAPPING, MISSING_TYPE_MAPPING, type.getName(), mapName); } + public static TranslatorConfigurationException missingTypeMapping(String typeName, String mapName) { + return new TranslatorConfigurationException(ErrorCode.MISSING_TYPE_MAPPING, MISSING_TYPE_MAPPING, typeName, mapName); + } + public static TranslatorConfigurationException missingTypeAnnotation(Class type) { return new TranslatorConfigurationException(ErrorCode.MISSING_TYPE_ANNOTATION, MISSING_TYPE_ANNOTATION, type.getName()); } diff --git a/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java b/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java index fbc0681f..49f28e9c 100644 --- a/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java +++ b/src/main/java/eu/europa/ted/efx/exceptions/TypeMismatchException.java @@ -27,14 +27,20 @@ public class TypeMismatchException extends EfxCompilationException { public enum ErrorCode { CANNOT_CONVERT, CANNOT_COMPARE, - EXPECTED_SCALAR, - EXPECTED_FIELD_CONTEXT + FIELD_MAY_REPEAT, + NODE_CONTEXT_AS_VALUE, + IDENTIFIER_IS_SEQUENCE, + IDENTIFIER_IS_SCALAR, + DICTIONARY_IS_SCALAR } private static final String CANNOT_CONVERT = "Type mismatch. Expected %s instead of %s."; - private static final String CANNOT_COMPARE = "Type mismatch. Cannot compare values of different types: %s and %s"; - private static final String EXPECTED_SCALAR = "Type mismatch. Field '%s' may return multiple values from context '%s', but is used as a scalar. Use a sequence expression or change the context."; - private static final String EXPECTED_FIELD_CONTEXT = "Type mismatch. Context variable '$%s' refers to node '%s', but is used as a value. Only field context variables can be used in value expressions."; + private static final String CANNOT_COMPARE = "Type mismatch. Cannot compare values of different types: %s and %s."; + private static final String FIELD_MAY_REPEAT = "Type mismatch. Field '%s' may return multiple values from context '%s', but is used as a scalar. Use a sequence expression or change the context."; + private static final String NODE_CONTEXT_AS_VALUE = "Type mismatch. Context variable '$%s' refers to node '%s', but is used as a value. Only field context variables can be used in value expressions."; + private static final String IDENTIFIER_IS_SEQUENCE = "Type mismatch. Variable '$%s' is declared as a sequence, but is used as a scalar."; + private static final String IDENTIFIER_IS_SCALAR = "Type mismatch. Variable '$%s' is declared as a scalar, but is used where a sequence is expected. To use it as a single-element sequence, wrap it in square brackets: [$%s]."; + private static final String DICTIONARY_IS_SCALAR = "Type mismatch. Dictionary lookup '$%s' returns a scalar, but is used where a sequence is expected. To use it as a single-element sequence, wrap it in square brackets: [$%s['key']]."; private final ErrorCode errorCode; @@ -73,11 +79,23 @@ public static TypeMismatchException cannotCompare(ParserRuleContext ctx, Express } public static TypeMismatchException fieldMayRepeat(ParserRuleContext ctx, String fieldId, String contextSymbol) { - return new TypeMismatchException(ErrorCode.EXPECTED_SCALAR, ctx, EXPECTED_SCALAR, fieldId, + return new TypeMismatchException(ErrorCode.FIELD_MAY_REPEAT, ctx, FIELD_MAY_REPEAT, fieldId, contextSymbol != null ? contextSymbol : "root"); } - public static TypeMismatchException nodesHaveNoValue(ParserRuleContext ctx, String variableName, String nodeId) { - return new TypeMismatchException(ErrorCode.EXPECTED_FIELD_CONTEXT, ctx, EXPECTED_FIELD_CONTEXT, variableName, nodeId); + public static TypeMismatchException nodeContextUsedAsValue(ParserRuleContext ctx, String variableName, String nodeId) { + return new TypeMismatchException(ErrorCode.NODE_CONTEXT_AS_VALUE, ctx, NODE_CONTEXT_AS_VALUE, variableName, nodeId); + } + + public static TypeMismatchException identifierIsSequence(ParserRuleContext ctx, String variableName) { + return new TypeMismatchException(ErrorCode.IDENTIFIER_IS_SEQUENCE, ctx, IDENTIFIER_IS_SEQUENCE, variableName); + } + + public static TypeMismatchException identifierIsScalar(ParserRuleContext ctx, String variableName) { + return new TypeMismatchException(ErrorCode.IDENTIFIER_IS_SCALAR, ctx, IDENTIFIER_IS_SCALAR, variableName, variableName); + } + + public static TypeMismatchException dictionaryIsScalar(ParserRuleContext ctx, String dictionaryName) { + return new TypeMismatchException(ErrorCode.DICTIONARY_IS_SCALAR, ctx, DICTIONARY_IS_SCALAR, dictionaryName, dictionaryName); } } diff --git a/src/main/java/eu/europa/ted/efx/model/types/EfxTypeTokenLookup.java b/src/main/java/eu/europa/ted/efx/model/types/EfxTypeTokenLookup.java new file mode 100644 index 00000000..f5eba188 --- /dev/null +++ b/src/main/java/eu/europa/ted/efx/model/types/EfxTypeTokenLookup.java @@ -0,0 +1,97 @@ +/* + * Copyright 2026 European Union + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European + * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in + * compliance with the Licence. You may obtain a copy of the Licence at: + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence + * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the Licence for the specific language governing permissions and limitations under + * the Licence. + */ +package eu.europa.ted.efx.model.types; + +import static java.util.Map.entry; + +import java.util.Map; + +import eu.europa.ted.efx.exceptions.TranslatorConfigurationException; +import eu.europa.ted.efx.sdk2.EfxLexer; + +/** + * Maps between different type name representations used in the EFX type system. + * Provides safe lookup methods that throw {@link TranslatorConfigurationException} + * when a mapping is missing, instead of silently returning null. + */ +public final class EfxTypeTokenLookup { + + public static final String TEXT = getLexerSymbol(EfxLexer.Text); + public static final String INDICATOR = getLexerSymbol(EfxLexer.Indicator); + public static final String NUMERIC = getLexerSymbol(EfxLexer.Number); + public static final String DATE = getLexerSymbol(EfxLexer.Date); + public static final String TIME = getLexerSymbol(EfxLexer.Time); + public static final String DURATION = getLexerSymbol(EfxLexer.Duration); + + private static final Map FROM_EFORMS_TYPE = Map.ofEntries( + entry(FieldTypes.ID.getName(), TEXT), + entry(FieldTypes.ID_REF.getName(), TEXT), + entry(FieldTypes.TEXT.getName(), TEXT), + entry(FieldTypes.TEXT_MULTILINGUAL.getName(), TEXT), + entry(FieldTypes.INDICATOR.getName(), INDICATOR), + entry(FieldTypes.AMOUNT.getName(), NUMERIC), + entry(FieldTypes.NUMBER.getName(), NUMERIC), + entry(FieldTypes.MEASURE.getName(), NUMERIC), + entry(FieldTypes.DURATION.getName(), DURATION), + entry(FieldTypes.CODE.getName(), TEXT), + entry(FieldTypes.INTERNAL_CODE.getName(), TEXT), + entry(FieldTypes.INTEGER.getName(), NUMERIC), + entry(FieldTypes.DATE.getName(), DATE), + entry(FieldTypes.ZONED_DATE.getName(), DATE), + entry(FieldTypes.TIME.getName(), TIME), + entry(FieldTypes.ZONED_TIME.getName(), TIME), + entry(FieldTypes.URL.getName(), TEXT), + entry(FieldTypes.PHONE.getName(), TEXT), + entry(FieldTypes.EMAIL.getName(), TEXT)); + + private static final Map, String> FROM_JAVA_TYPE = Map.ofEntries( + entry(EfxDataType.String.class, TEXT), + entry(EfxDataType.Boolean.class, INDICATOR), + entry(EfxDataType.Number.class, NUMERIC), + entry(EfxDataType.Duration.class, DURATION), + entry(EfxDataType.Date.class, DATE), + entry(EfxDataType.Time.class, TIME)); + + /** + * Resolves an eForms SDK field type name to the corresponding EFX type name. + * + * @throws TranslatorConfigurationException if the type is not mapped + */ + public static String fromEformsType(String eformsType) { + String result = FROM_EFORMS_TYPE.get(eformsType); + if (result == null) { + throw TranslatorConfigurationException.missingTypeMapping(eformsType, "EfxTypeTokenLookup.FROM_EFORMS_TYPE"); + } + return result; + } + + /** + * Resolves a Java EfxDataType class to the corresponding EFX type name. + * + * @throws TranslatorConfigurationException if the type is not mapped + */ + public static String fromJavaType(Class javaType) { + String result = FROM_JAVA_TYPE.get(javaType); + if (result == null) { + throw TranslatorConfigurationException.missingTypeMapping(javaType, "EfxTypeTokenLookup.FROM_JAVA_TYPE"); + } + return result; + } + + private static String getLexerSymbol(int tokenType) { + return EfxLexer.VOCABULARY.getLiteralName(tokenType).replaceAll("^'|'$", ""); + } + + private EfxTypeTokenLookup() {} +} diff --git a/src/main/java/eu/europa/ted/efx/model/variables/Dictionary.java b/src/main/java/eu/europa/ted/efx/model/variables/Dictionary.java index 95263fbe..fcbd1c56 100644 --- a/src/main/java/eu/europa/ted/efx/model/variables/Dictionary.java +++ b/src/main/java/eu/europa/ted/efx/model/variables/Dictionary.java @@ -13,6 +13,8 @@ */ package eu.europa.ted.efx.model.variables; +import java.util.Objects; + import eu.europa.ted.efx.model.expressions.PathExpression; import eu.europa.ted.efx.model.expressions.TypedExpression; import eu.europa.ted.efx.model.expressions.scalar.StringExpression; @@ -29,8 +31,7 @@ public class Dictionary extends Identifier { public final Class type; public Dictionary(String dictionaryName, PathExpression pathExpression, StringExpression keyExpression) { - - super(dictionaryName, keyExpression.getDataType()); + super(dictionaryName, pathExpression.getDataType()); this.keyExpression = keyExpression; this.pathExpression = pathExpression; this.type = pathExpression.getClass(); @@ -42,9 +43,9 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; Dictionary dictionary = (Dictionary) o; - return java.util.Objects.equals(keyExpression, dictionary.keyExpression) - && java.util.Objects.equals(pathExpression, dictionary.pathExpression) - && java.util.Objects.equals(type, dictionary.type); + return Objects.equals(keyExpression, dictionary.keyExpression) + && Objects.equals(pathExpression, dictionary.pathExpression) + && Objects.equals(type, dictionary.type); } @Override diff --git a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java index d3848181..0e84f8be 100644 --- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java +++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java @@ -15,9 +15,11 @@ import static java.util.Map.entry; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Deque; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -75,6 +77,7 @@ import eu.europa.ted.efx.model.expressions.sequence.TimeSequenceExpression; import eu.europa.ted.efx.model.types.EfxDataType; import eu.europa.ted.efx.model.types.EfxTypeLattice; +import eu.europa.ted.efx.model.types.EfxTypeTokenLookup; import eu.europa.ted.efx.model.types.FieldTypes; import eu.europa.ted.efx.model.variables.ParsedArguments; import eu.europa.ted.efx.model.variables.Function; @@ -85,7 +88,7 @@ import eu.europa.ted.efx.sdk2.EfxParser.*; /** - * The the goal of the EfxExpressionTranslator is to take an EFX expression and translate it to a + * The goal of the EfxExpressionTranslator is to take an EFX expression and translate it to a * target scripting language. * * The target language syntax is not hardcoded into the translator so that this class can be reused @@ -257,11 +260,11 @@ protected String getFieldId(FieldReferenceContext ctx) { } if (ctx.absoluteFieldReference() != null) { - return this.getFieldId(ctx.absoluteFieldReference()); + return getFieldId(ctx.absoluteFieldReference()); } if (ctx.fieldReferenceInOtherNotice() != null) { - return this.getFieldId(ctx.fieldReferenceInOtherNotice()); + return getFieldId(ctx.fieldReferenceInOtherNotice()); } assert false : "Unexpected context type for field reference: " + ctx.getClass().getSimpleName(); return null; @@ -271,14 +274,14 @@ protected String getFieldId(AbsoluteFieldReferenceContext ctx) { if (ctx == null) { return null; } - return this.getFieldId(ctx.reference.reference); + return getFieldId(ctx.reference.reference); } protected String getFieldId(FieldReferenceInOtherNoticeContext ctx) { if (ctx == null) { return null; } - return this.getFieldId(ctx.reference.reference.reference.reference.reference); + return getFieldId(ctx.reference.reference.reference.reference.reference); } protected String getFieldId(FieldContextContext ctx) { @@ -287,11 +290,11 @@ protected String getFieldId(FieldContextContext ctx) { } if (ctx.absoluteFieldReference() != null) { - return this.getFieldId(ctx.absoluteFieldReference()); + return getFieldId(ctx.absoluteFieldReference()); } if (ctx.fieldReferenceWithPredicate() != null) { - return this.getFieldId(ctx.fieldReferenceWithPredicate()); + return getFieldId(ctx.fieldReferenceWithPredicate()); } assert false : "Unexpected context type for field reference: " + ctx.getClass().getSimpleName(); @@ -302,7 +305,7 @@ protected String getFieldId(FieldReferenceWithPredicateContext ctx) { if (ctx == null) { return null; } - return this.getFieldId(ctx.linkedFieldReference()); + return getFieldId(ctx.linkedFieldReference()); } protected static String getNodeId(NodeReferenceContext ctx) { @@ -2842,44 +2845,13 @@ public void exitLateBoundScalar(LateBoundScalarContext ctx) { assert false: "This should have been handled by the preprocessor: " + ctx.getText() +". Check any changes that you might have made in the EFX grammar that may have broken this assumption."; } - // Type name constants - made protected for reuse in subclasses - protected static String textTypeName = getLexerSymbol(EfxLexer.Text); - protected static String booleanTypeName = getLexerSymbol(EfxLexer.Indicator); - protected static String numericTypeName = getLexerSymbol(EfxLexer.Number); - protected static String dateTypeName = getLexerSymbol(EfxLexer.Date); - protected static String timeTypeName = getLexerSymbol(EfxLexer.Time); - protected static String durationTypeName = getLexerSymbol(EfxLexer.Duration); - - // Map from eForms field types to EFX type names - made protected for reuse in subclasses - protected static final Map eFormsToEfxTypeMap = Map.ofEntries( // - entry(FieldTypes.ID.getName(), textTypeName), // - entry(FieldTypes.ID_REF.getName(), textTypeName), // - entry(FieldTypes.TEXT.getName(), textTypeName), // - entry(FieldTypes.TEXT_MULTILINGUAL.getName(), textTypeName), // - entry(FieldTypes.INDICATOR.getName(), booleanTypeName), // - entry(FieldTypes.AMOUNT.getName(), numericTypeName), // - entry(FieldTypes.NUMBER.getName(), numericTypeName), // - entry(FieldTypes.MEASURE.getName(), numericTypeName), // - entry(FieldTypes.DURATION.getName(), durationTypeName), // - entry(FieldTypes.CODE.getName(), textTypeName), // - entry(FieldTypes.INTERNAL_CODE.getName(), textTypeName), // - entry(FieldTypes.INTEGER.getName(), numericTypeName), // - entry(FieldTypes.DATE.getName(), dateTypeName), // - entry(FieldTypes.ZONED_DATE.getName(), dateTypeName), // - entry(FieldTypes.TIME.getName(), timeTypeName), // - entry(FieldTypes.ZONED_TIME.getName(), timeTypeName), // - entry(FieldTypes.URL.getName(), textTypeName), // - entry(FieldTypes.PHONE.getName(), textTypeName), // - entry(FieldTypes.EMAIL.getName(), textTypeName)); - - // Map from Java EfxDataType classes to EFX type names - made protected for reuse in subclasses - protected static final Map, String> javaToEfxTypeMap = Map.ofEntries( - entry(EfxDataType.String.class, textTypeName), // - entry(EfxDataType.Boolean.class, booleanTypeName), // - entry(EfxDataType.Number.class, numericTypeName), // - entry(EfxDataType.Duration.class, durationTypeName), // - entry(EfxDataType.Date.class, dateTypeName), // - entry(EfxDataType.Time.class, timeTypeName)); + + private enum CardinalityResolutionContext { + RESOLVED, + RESOLVE_SCALAR, + RESOLVE_SEQUENCE, + RESOLVE_EITHER + } /** * The EFX expression pre-processor is used to remove expression ambiguities @@ -2908,6 +2880,7 @@ class ExpressionPreprocessor extends EfxBaseListener { final TokenStreamRewriter rewriter; final CallStack stack = new CallStack(); final ContextStack efxContext; + final Deque typeResolutionStack = new ArrayDeque<>(); ExpressionPreprocessor(String expression) { this(CharStreams.fromString(expression)); @@ -2941,7 +2914,7 @@ String processExpression() { return this.rewriter.getText(); } - // #region Context tracking ----------------------------------------------- + // #region EFX context tracking ------------------------------------------- @Override public void enterSingleExpression(SingleExpressionContext ctx) { @@ -3097,45 +3070,99 @@ public void exitFieldReferenceWithVariableContextOverride( } } - // #endregion Context tracking -------------------------------------------- + // #endregion EFX context tracking ---------------------------------------- + + // #region Rewrite context stack ------------------------------------------- @Override - public void exitScalarFromFieldReference(ScalarFromFieldReferenceContext ctx) { - String fieldId = getFieldId(ctx.fieldReference()); - String fieldType = eFormsToEfxTypeMap.get(this.symbols.getTypeOfField(fieldId)); - - // Skip repeatability check if context IS this field (e.g., inside a predicate on this field). - // In that case, we're referencing the current element being iterated, not the whole sequence. - if (this.efxContext.isEmpty() || !fieldId.equals(this.efxContext.symbol())) { - String contextNodeId = getContextNodeId(); - if (this.symbols.isFieldRepeatableFromContext(fieldId, contextNodeId)) { - // B2 Solution 3: If we're in a top-level expression context where sequences - // are valid (lateBoundScalar → lateBoundExpression → expression), auto-insert - // sequence cast instead of throwing. - if (isTopLevelLateBoundExpression(ctx)) { - this.rewriter.insertBefore(ctx.getStart(), "(" + fieldType + "*)"); - return; - } - // If inside a for-return body, auto-insert sequence cast so the re-parsed - // expression matches the ConcatenatedIterations rule (flatMap semantics). - if (hasParentContextOfType(ctx, LateBoundSequenceFromIterationContext.class)) { - this.rewriter.insertBefore(ctx.getStart(), "(" + fieldType + "*)"); - return; - } - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); - } - } + public void enterLateBoundScalar(LateBoundScalarContext ctx) { + this.typeResolutionStack.push( + this.adjustForGrammarAmbiguities(ctx, CardinalityResolutionContext.RESOLVE_SCALAR)); + } - if (!hasParentContextOfType(ctx, LateBoundScalarContext.class)) { - return; - } + @Override + public void exitLateBoundScalar(LateBoundScalarContext ctx) { + this.typeResolutionStack.pop(); + } + + @Override + public void enterLateBoundSequence(LateBoundSequenceContext ctx) { + this.typeResolutionStack.push( + this.adjustForGrammarAmbiguities(ctx, CardinalityResolutionContext.RESOLVE_SEQUENCE)); + } + + @Override + public void exitLateBoundSequence(LateBoundSequenceContext ctx) { + this.typeResolutionStack.pop(); + } - // Insert the type cast - this.rewriter.insertBefore(ctx.getStart(), "(" + fieldType + ")"); + /** + * Checks whether the given late-bound node sits in a grammar position where + * cardinality is ambiguous, and returns {@code RESOLVE_EITHER} if so. + * Otherwise returns the provided default context. + * + * There are two grammar ambiguities that require this adjustment: + * + * Ambiguity 1: The {@code expression} rule accepts both + * {@code lateBoundScalar} and {@code lateBoundSequence} via + * {@code lateBoundExpression}. The parser picks one alternative, but the + * actual cardinality is unknown until the preprocessor resolves it. + * + * Ambiguity 2: The {@code for...return} construct has two rules: + * {@code lateBoundSequenceFromIteration} (scalar body) and + * {@code lateBoundSequenceFromConcatenatedIterations} (sequence body). + * The parser picks one, but the body cardinality is unknown until resolved. + * + * @param ctx the late-bound parse tree node (scalar or sequence) + * @param defaultContext the context to use when no ambiguity is detected + * @return {@code RESOLVE_EITHER} if an ambiguity applies, otherwise + * {@code defaultContext} + */ + private CardinalityResolutionContext adjustForGrammarAmbiguities(ParserRuleContext ctx, + CardinalityResolutionContext defaultContext) { + ParserRuleContext parent = ctx.getParent(); + + // Ambiguity 1: expression → lateBoundExpression → lateBoundScalar | lateBoundSequence + boolean ambiguity1 = (parent instanceof LateBoundExpressionContext) + && (parent.getParent() instanceof ExpressionContext); + + // Ambiguity 2: lateBoundSequenceFromIteration vs lateBoundSequenceFromConcatenatedIterations + boolean ambiguity2 = (parent instanceof LateBoundSequenceFromIterationContext) + || (parent instanceof LateBoundSequenceFromConcatenatedIterationsContext); + + return (ambiguity1 || ambiguity2) + ? CardinalityResolutionContext.RESOLVE_EITHER + : defaultContext; + } + + private CardinalityResolutionContext currentCardinalityResolutionContext() { + return this.typeResolutionStack.isEmpty() ? CardinalityResolutionContext.RESOLVED : this.typeResolutionStack.peek(); + } + + + /** + * Returns true if a field is repeatable from the current EFX context. + * A field is not considered repeatable if it IS the current context. + */ + private boolean isFieldRepeatableFromCurrentContext(String fieldId) { + if (!this.efxContext.isEmpty() && fieldId.equals(this.efxContext.symbol())) { + return false; + } + return this.symbols.isFieldRepeatableFromContext(fieldId, this.getContextNodeId()); } + // #endregion Rewrite context stack ---------------------------------------- + + // #region Type cast insertion --------------------------------------------- + // + // Each reference type (field, attribute, function, variable, dictionary) can + // appear in both lateBoundScalarReference and lateBoundSequenceReference. + // Both paths delegate to a single shared method that consults the + // typeResolutionStack to decide scalar (type) vs sequence (type*) cast. + /** - * Gets the node ID of the current context for use with isFieldRepeatableFromContext. + * Gets the node ID of the current context for use with + * isFieldRepeatableFromContext. * If the context is a NodeContext, returns the node ID directly. * If the context is a FieldContext, returns the parent node of that field. * If the context is empty/null, returns null (root context). @@ -3153,219 +3180,293 @@ private String getContextNodeId() { } } - @Override - public void exitScalarFromAttributeReference(ScalarFromAttributeReferenceContext ctx) { - if (!hasParentContextOfType(ctx, LateBoundScalarContext.class)) { - return; + /** + * Resolves cardinality for a field or attribute reference. + * Fields have contextual cardinality: the same field may be scalar or sequence + * depending on the evaluation context. Because of this, a non-repeatable field + * in a sequence context is silently promoted to a single-element sequence rather + * than rejected as an error. + * + * @param ctx the parse tree context of the reference + * @param fieldRef the field reference used to look up repeatability + * @param typeName the EFX type name to insert as a cast prefix + */ + private void resolveFieldOrAttributeReference(ParserRuleContext ctx, FieldReferenceContext fieldRef, String typeName) { + switch (this.currentCardinalityResolutionContext()) { + case RESOLVED: + return; + case RESOLVE_SEQUENCE: { + this.rewriter.insertBefore(ctx.getStart(), "(" + typeName + "*)"); + break; + } + case RESOLVE_EITHER: { + String suffix = this.isFieldRepeatableFromCurrentContext(getFieldId(fieldRef)) ? "*" : ""; + this.rewriter.insertBefore(ctx.getStart(), "(" + typeName + suffix + ")"); + break; + } + case RESOLVE_SCALAR: { + String fieldId = getFieldId(fieldRef); + if (this.isFieldRepeatableFromCurrentContext(fieldId)) { + throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); + } + this.rewriter.insertBefore(ctx.getStart(), "(" + typeName + ")"); + break; + } } - - // Insert the type cast. For attributes, the type is always text. - this.rewriter.insertBefore(ctx.getStart(), "(" + textTypeName + ")"); } - @Override - public void exitScalarFromFunctionInvocation(ScalarFromFunctionInvocationContext ctx) { - if (!hasParentContextOfType(ctx, LateBoundScalarContext.class)) { - return; - } - - String functionName = ctx.functionInvocation().functionName.getText(); - String functionType = javaToEfxTypeMap.get(EfxTypeLattice.toPrimitive(this.stack.getTypeOfIdentifier(functionName))); - - if (functionType != null) { - // Insert the type cast - this.rewriter.insertBefore(ctx.functionInvocation().FunctionPrefix().getSymbol(), "(" + functionType + ")"); - } + /** + * Resolves cardinality for a field reference by looking up its type from + * the SDK metadata and delegating to {@code resolveFieldOrAttributeReference}. + * + * @param ctx the parse tree context of the reference + * @param fieldRef the field reference used to look up the field type + */ + private void resolveFieldReference(ParserRuleContext ctx, FieldReferenceContext fieldRef) { + String fieldType = EfxTypeTokenLookup.fromEformsType(this.symbols.getTypeOfField(getFieldId(fieldRef))); + this.resolveFieldOrAttributeReference(ctx, fieldRef, fieldType); } - @Override - public void exitSequenceFromFieldReference(SequenceFromFieldReferenceContext ctx) { - if (!hasParentContextOfType(ctx, LateBoundSequenceContext.class)) { - return; - } - - // Find the referenced field and get its type - String fieldId = getFieldId(ctx.fieldReference()); - String fieldType = eFormsToEfxTypeMap.get(this.symbols.getTypeOfField(fieldId)); + /** + * Resolves cardinality for an attribute reference. The attribute type is + * hardcoded as text because the symbol resolver does not yet support + * resolving an attribute reference to its field ID. + * + * @param ctx the attribute reference parse tree context + */ + private void resolveAttributeReference(AttributeReferenceContext ctx) { + this.resolveFieldOrAttributeReference(ctx, ctx.fieldReference(), EfxTypeTokenLookup.TEXT); + } - if (fieldType != null) { - // Insert the sequence type cast (type*) - this.rewriter.insertBefore(ctx.getStart(), "(" + fieldType + "*)"); + /** + * Resolves cardinality for a user-defined function invocation. + * Functions have explicit cardinality from their declaration (scalar or + * sequence return type), so a scalar function in sequence context or + * vice versa is an error. + * + * @param ctx the parse tree context of the enclosing expression + * @param funcCtx the function invocation parse tree context + */ + private void resolveFunctionInvocation(ParserRuleContext ctx, FunctionInvocationContext funcCtx) { + switch (this.currentCardinalityResolutionContext()) { + case RESOLVED: + return; + case RESOLVE_SEQUENCE: { + Class returnType = this.stack.getTypeOfIdentifier(funcCtx.functionName.getText()); + if (!EfxDataType.Cardinality.Sequence.class.isAssignableFrom(returnType)) { + throw TypeMismatchException.identifierIsScalar(ctx, funcCtx.functionName.getText()); + } + String typeCast = EfxTypeTokenLookup.fromJavaType(EfxTypeLattice.toPrimitive(returnType)); + this.rewriter.insertBefore(funcCtx.FunctionPrefix().getSymbol(), "(" + typeCast + "*)"); + break; + } + case RESOLVE_EITHER: { + Class returnType = this.stack.getTypeOfIdentifier(funcCtx.functionName.getText()); + String typeCast = EfxTypeTokenLookup.fromJavaType(EfxTypeLattice.toPrimitive(returnType)); + String suffix = EfxDataType.Cardinality.Sequence.class.isAssignableFrom(returnType) ? "*" : ""; + this.rewriter.insertBefore(funcCtx.FunctionPrefix().getSymbol(), "(" + typeCast + suffix + ")"); + break; + } + case RESOLVE_SCALAR: { + Class returnType = this.stack.getTypeOfIdentifier(funcCtx.functionName.getText()); + if (EfxDataType.Cardinality.Sequence.class.isAssignableFrom(returnType)) { + throw TypeMismatchException.identifierIsSequence(ctx, funcCtx.functionName.getText()); + } + String typeCast = EfxTypeTokenLookup.fromJavaType(EfxTypeLattice.toPrimitive(returnType)); + this.rewriter.insertBefore(funcCtx.FunctionPrefix().getSymbol(), "(" + typeCast + ")"); + break; + } } } - @Override - public void exitSequenceFromAttributeReference(SequenceFromAttributeReferenceContext ctx) { - if (!hasParentContextOfType(ctx, LateBoundSequenceContext.class)) { - return; + /** + * Dispatches cardinality resolution for a variable reference to either + * {@code resolveContextVariableReference} or {@code resolveRegularVariableReference}, + * depending on whether the variable was declared with {@code context:}. + * + * @param ctx the variable reference parse tree context + */ + private void resolveVariableReference(VariableReferenceContext ctx) { + if (this.efxContext.getContextFromVariable(ctx.variableName.getText()) != null) { + this.resolveContextVariableReference(ctx); + } else { + this.resolveRegularVariableReference(ctx); } - - // Insert the sequence type cast (text*) - this.rewriter.insertBefore(ctx.getStart(), "(" + textTypeName + "*)"); } - @Override - public void exitSequenceFromFunctionInvocation(SequenceFromFunctionInvocationContext ctx) { - if (!hasParentContextOfType(ctx, LateBoundSequenceContext.class)) { - return; + /** + * Resolves type for a context variable reference (declared with {@code context:}). + * Context variables are always scalar because the iterator binds them to a single + * instance, so using one in a sequence context is an error. Node context variables + * cannot be used as values at all. + * + * @param ctx the variable reference parse tree context + */ + private void resolveContextVariableReference(VariableReferenceContext ctx) { + Context variableContext = this.efxContext.getContextFromVariable(ctx.variableName.getText()); + if (variableContext.isNodeContext()) { + throw TypeMismatchException.nodeContextUsedAsValue(ctx, ctx.variableName.getText(), variableContext.symbol()); } - String functionName = ctx.functionInvocation().functionName.getText(); - String functionType = javaToEfxTypeMap.get(EfxTypeLattice.toPrimitive(this.stack.getTypeOfIdentifier(functionName))); - - if (functionType != null) { - // Insert the sequence type cast (type*) - this.rewriter.insertBefore(ctx.functionInvocation().FunctionPrefix().getSymbol(), "(" + functionType + "*)"); + switch (this.currentCardinalityResolutionContext()) { + case RESOLVED: + return; + case RESOLVE_SEQUENCE: + throw TypeMismatchException.identifierIsScalar(ctx, ctx.variableName.getText()); + case RESOLVE_EITHER: + case RESOLVE_SCALAR: { + String typeCast = EfxTypeTokenLookup.fromEformsType(this.symbols.getTypeOfField(variableContext.symbol())); + this.rewriter.insertBefore(ctx.VariablePrefix().getSymbol(), "(" + typeCast + ")"); + break; + } } } - @Override - public void exitScalarFromVariableReference(ScalarFromVariableReferenceContext ctx) { - String variableName = ctx.variableReference().variableName.getText(); - Context variableContext = this.efxContext.getContextFromVariable(variableName); - - // Guard: Node context variables cannot be used as values - if (variableContext != null && variableContext.isNodeContext()) { - throw TypeMismatchException.nodesHaveNoValue(ctx, variableName, variableContext.symbol()); + /** + * Resolves type for a regular (non-context) variable reference. + * Variables have explicit cardinality from their declaration, so a scalar + * variable in sequence context (or vice versa) is an error. + * + * @param ctx the variable reference parse tree context + */ + private void resolveRegularVariableReference(VariableReferenceContext ctx) { + switch (this.currentCardinalityResolutionContext()) { + case RESOLVED: + return; + case RESOLVE_SEQUENCE: { + Class stackType = this.stack.getTypeOfIdentifier(ctx.variableName.getText()); + if (!EfxDataType.Cardinality.Sequence.class.isAssignableFrom(stackType)) { + throw TypeMismatchException.identifierIsScalar(ctx, ctx.variableName.getText()); + } + String typeCast = EfxTypeTokenLookup.fromJavaType(EfxTypeLattice.toPrimitive(stackType)); + this.rewriter.insertBefore(ctx.VariablePrefix().getSymbol(), "(" + typeCast + "*)"); + break; + } + case RESOLVE_EITHER: { + Class stackType = this.stack.getTypeOfIdentifier(ctx.variableName.getText()); + String typeCast = EfxTypeTokenLookup.fromJavaType(EfxTypeLattice.toPrimitive(stackType)); + String suffix = EfxDataType.Cardinality.Sequence.class.isAssignableFrom(stackType) ? "*" : ""; + this.rewriter.insertBefore(ctx.VariablePrefix().getSymbol(), "(" + typeCast + suffix + ")"); + break; + } + case RESOLVE_SCALAR: { + Class stackType = this.stack.getTypeOfIdentifier(ctx.variableName.getText()); + if (EfxDataType.Cardinality.Sequence.class.isAssignableFrom(stackType)) { + throw TypeMismatchException.identifierIsSequence(ctx, ctx.variableName.getText()); + } + String typeCast = EfxTypeTokenLookup.fromJavaType(EfxTypeLattice.toPrimitive(stackType)); + this.rewriter.insertBefore(ctx.VariablePrefix().getSymbol(), "(" + typeCast + ")"); + break; + } } + } - // Guard: Field context variables must not be repeatable in scalar context - if (variableContext != null && variableContext.isFieldContext()) { - String fieldId = variableContext.symbol(); - if (this.symbols.isFieldRepeatableFromContext(fieldId, getContextNodeId())) { - throw TypeMismatchException.fieldMayRepeat(ctx, fieldId, this.efxContext.symbol()); + /** + * Resolves type for a dictionary lookup reference. + * Dictionaries always return a scalar value (one key maps to one value), + * so using a dictionary lookup in a sequence context is an error. + * + * @param ctx the dictionary lookup parse tree context + */ + private void resolveDictionaryLookup(DictionaryLookupContext ctx) { + switch (this.currentCardinalityResolutionContext()) { + case RESOLVED: + return; + case RESOLVE_SEQUENCE: + throw TypeMismatchException.dictionaryIsScalar(ctx, ctx.dictionaryName.getText()); + case RESOLVE_EITHER: + case RESOLVE_SCALAR: { + String typeCast = EfxTypeTokenLookup.fromJavaType( + EfxTypeLattice.toPrimitive(this.stack.getTypeOfIdentifier(ctx.dictionaryName.getText()))); + this.rewriter.insertBefore(ctx.VariablePrefix().getSymbol(), "(" + typeCast + ")"); + break; } } + } - // Guard: Skip type cast insertion if not in late-bound context (explicit cast already present) - if (!hasParentContextOfType(ctx, LateBoundScalarContext.class)) { - return; - } + // #endregion Type cast insertion ------------------------------------------ - // Determine type for cast: field type for context variables, variable type for regular variables - String typeCast = (variableContext != null) - ? eFormsToEfxTypeMap.get(this.symbols.getTypeOfField(variableContext.symbol())) - : javaToEfxTypeMap.get(EfxTypeLattice.toPrimitive(this.stack.getTypeOfIdentifier(variableName))); + // #region Late-bound type resolution -------------------------------------- - if (typeCast != null) { - this.rewriter.insertBefore(ctx.variableReference().VariablePrefix().getSymbol(), "(" + typeCast + ")"); - } + @Override + public void exitScalarFromFieldReference(ScalarFromFieldReferenceContext ctx) { + this.resolveFieldReference(ctx, ctx.fieldReference()); } @Override - public void exitSequenceFromVariableReference(SequenceFromVariableReferenceContext ctx) { - String variableName = ctx.variableReference().variableName.getText(); - Context variableContext = this.efxContext.getContextFromVariable(variableName); - - // Guard: Node context variables cannot be used as values - if (variableContext != null && variableContext.isNodeContext()) { - throw TypeMismatchException.nodesHaveNoValue(ctx, variableName, variableContext.symbol()); - } - - // No repeatability check needed - sequences can have multiple values - - // Guard: Skip type cast insertion if not in late-bound context (explicit cast already present) - if (!hasParentContextOfType(ctx, LateBoundSequenceContext.class)) { - return; - } + public void exitSequenceFromFieldReference(SequenceFromFieldReferenceContext ctx) { + this.resolveFieldReference(ctx, ctx.fieldReference()); + } - // Determine type for cast: field type for context variables, variable type for regular variables - String typeCast = (variableContext != null) - ? eFormsToEfxTypeMap.get(this.symbols.getTypeOfField(variableContext.symbol())) - : javaToEfxTypeMap.get(EfxTypeLattice.toPrimitive(this.stack.getTypeOfIdentifier(variableName))); + @Override + public void exitScalarFromAttributeReference(ScalarFromAttributeReferenceContext ctx) { + this.resolveAttributeReference(ctx.attributeReference()); + } - if (typeCast != null) { - this.rewriter.insertBefore(ctx.variableReference().VariablePrefix().getSymbol(), "(" + typeCast + "*)"); - } + @Override + public void exitSequenceFromAttributeReference(SequenceFromAttributeReferenceContext ctx) { + this.resolveAttributeReference(ctx.attributeReference()); } @Override - public void exitDictionaryLookup(DictionaryLookupContext ctx) { - if (!hasParentContextOfType(ctx, LateBoundScalarContext.class)) { - return; - } + public void exitScalarFromFunctionInvocation(ScalarFromFunctionInvocationContext ctx) { + this.resolveFunctionInvocation(ctx, ctx.functionInvocation()); + } - String dictionaryName = ctx.dictionaryName.getText(); - String dictionaryType = javaToEfxTypeMap.get(EfxTypeLattice.toPrimitive(this.stack.getTypeOfIdentifier(dictionaryName))); + @Override + public void exitSequenceFromFunctionInvocation(SequenceFromFunctionInvocationContext ctx) { + this.resolveFunctionInvocation(ctx, ctx.functionInvocation()); + } - if (dictionaryType != null) { - // Insert the type cast - this.rewriter.insertBefore(ctx.VariablePrefix().getSymbol(), "(" + dictionaryType + ")"); - } + @Override + public void exitScalarFromVariableReference(ScalarFromVariableReferenceContext ctx) { + this.resolveVariableReference(ctx.variableReference()); } - boolean hasParentContextOfType(ParserRuleContext ctx, Class parentClass) { - ParserRuleContext parent = ctx.getParent(); - while (parent != null) { - if (parentClass.isInstance(parent)) { - return true; - } - parent = parent.getParent(); - } - return false; + @Override + public void exitSequenceFromVariableReference(SequenceFromVariableReferenceContext ctx) { + this.resolveVariableReference(ctx.variableReference()); } - /** - * Checks if we're in a top-level late-bound expression context where sequences are valid. - * This is the path: lateBoundScalar → lateBoundExpression → expression - * In this path, the grammar allows either scalars or sequences, so a repeatable field - * can be auto-cast to sequence instead of throwing an error. - */ - private boolean isTopLevelLateBoundExpression(ParserRuleContext ctx) { - // Walk up to find LateBoundScalarContext - ParserRuleContext current = ctx; - while (current != null && !(current instanceof LateBoundScalarContext)) { - current = current.getParent(); - } - if (current == null) { - return false; - } - // Check if LateBoundScalarContext's parent is LateBoundExpressionContext - ParserRuleContext parent = current.getParent(); - if (!(parent instanceof LateBoundExpressionContext)) { - return false; - } - // Check if LateBoundExpressionContext's parent is ExpressionContext - ParserRuleContext grandparent = parent.getParent(); - return grandparent instanceof ExpressionContext; + @Override + public void exitDictionaryLookup(DictionaryLookupContext ctx) { + this.resolveDictionaryLookup(ctx); } + // #endregion Late-bound type resolution ----------------------------------- + + // #region Symbol declarations --------------------------------------------- + // #region Variable declarations ------------------------------------------ @Override public void exitStringIteratorVariableDeclaration(StringIteratorVariableDeclarationContext ctx) { - String variableName = ctx.variableName.getText(); - this.stack.declareIdentifier(new Variable(variableName, StringExpression.empty(), StringExpression.empty())); + this.stack.declareIdentifier(new Variable(ctx.variableName.getText(), StringExpression.empty(), StringExpression.empty())); } @Override public void exitBooleanIteratorVariableDeclaration(BooleanIteratorVariableDeclarationContext ctx) { - String variableName = ctx.variableName.getText(); - this.stack.declareIdentifier(new Variable(variableName, BooleanExpression.empty(), BooleanExpression.empty())); + this.stack.declareIdentifier(new Variable(ctx.variableName.getText(), BooleanExpression.empty(), BooleanExpression.empty())); } @Override public void exitNumericIteratorVariableDeclaration(NumericIteratorVariableDeclarationContext ctx) { - String variableName = ctx.variableName.getText(); - this.stack.declareIdentifier(new Variable(variableName, NumericExpression.empty(), NumericExpression.empty())); + this.stack.declareIdentifier(new Variable(ctx.variableName.getText(), NumericExpression.empty(), NumericExpression.empty())); } @Override public void exitDateIteratorVariableDeclaration(DateIteratorVariableDeclarationContext ctx) { - String variableName = ctx.variableName.getText(); - this.stack.declareIdentifier(new Variable(variableName, DateExpression.empty(), DateExpression.empty())); + this.stack.declareIdentifier(new Variable(ctx.variableName.getText(), DateExpression.empty(), DateExpression.empty())); } @Override public void exitTimeIteratorVariableDeclaration(TimeIteratorVariableDeclarationContext ctx) { - String variableName = ctx.variableName.getText(); - this.stack.declareIdentifier(new Variable(variableName, TimeExpression.empty(), TimeExpression.empty())); + this.stack.declareIdentifier(new Variable(ctx.variableName.getText(), TimeExpression.empty(), TimeExpression.empty())); } @Override public void exitDurationIteratorVariableDeclaration(DurationIteratorVariableDeclarationContext ctx) { - String variableName = ctx.variableName.getText(); - this.stack.declareIdentifier(new Variable(variableName, DurationExpression.empty(), DurationExpression.empty())); + this.stack.declareIdentifier(new Variable(ctx.variableName.getText(), DurationExpression.empty(), DurationExpression.empty())); } @Override @@ -3393,75 +3494,63 @@ public void exitContextIteratorExpression(ContextIteratorExpressionContext ctx) @Override public void exitStringParameterDeclaration(StringParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, StringExpression.empty())); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), StringExpression.empty())); } @Override public void exitNumericParameterDeclaration(NumericParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, NumericExpression.empty())); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), NumericExpression.empty())); } @Override public void exitBooleanParameterDeclaration(BooleanParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, BooleanExpression.empty())); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), BooleanExpression.empty())); } @Override public void exitDateParameterDeclaration(DateParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, DateExpression.empty())); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), DateExpression.empty())); } @Override public void exitTimeParameterDeclaration(TimeParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, TimeExpression.empty())); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), TimeExpression.empty())); } @Override public void exitDurationParameterDeclaration(DurationParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, DurationExpression.empty())); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), DurationExpression.empty())); } // Sequence parameter declarations @Override public void exitStringSequenceParameterDeclaration(StringSequenceParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, new StringSequenceExpression(""))); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), new StringSequenceExpression(""))); } @Override public void exitNumericSequenceParameterDeclaration(NumericSequenceParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, new NumericSequenceExpression(""))); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), new NumericSequenceExpression(""))); } @Override public void exitBooleanSequenceParameterDeclaration(BooleanSequenceParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, new BooleanSequenceExpression(""))); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), new BooleanSequenceExpression(""))); } @Override public void exitDateSequenceParameterDeclaration(DateSequenceParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, new DateSequenceExpression(""))); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), new DateSequenceExpression(""))); } @Override public void exitTimeSequenceParameterDeclaration(TimeSequenceParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, new TimeSequenceExpression(""))); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), new TimeSequenceExpression(""))); } @Override public void exitDurationSequenceParameterDeclaration(DurationSequenceParameterDeclarationContext ctx) { - String identifier = ctx.parameterName.getText(); - this.stack.declareIdentifier(new ParsedParameter(identifier, new DurationSequenceExpression(""))); + this.stack.declareIdentifier(new ParsedParameter(ctx.parameterName.getText(), new DurationSequenceExpression(""))); } // #endregion Parameter declarations -------------------------------------- @@ -3470,82 +3559,72 @@ public void exitDurationSequenceParameterDeclaration(DurationSequenceParameterDe @Override public void exitStringFunctionDeclaration(StringFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), StringExpression.empty())); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), StringExpression.empty())); } @Override public void exitNumericFunctionDeclaration(NumericFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), NumericExpression.empty())); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), NumericExpression.empty())); } @Override public void exitBooleanFunctionDeclaration(BooleanFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), BooleanExpression.empty())); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), BooleanExpression.empty())); } @Override public void exitDateFunctionDeclaration(DateFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), DateExpression.empty())); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), DateExpression.empty())); } @Override public void exitTimeFunctionDeclaration(TimeFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), TimeExpression.empty())); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), TimeExpression.empty())); } @Override public void exitDurationFunctionDeclaration(DurationFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), DurationExpression.empty())); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), DurationExpression.empty())); } // Sequence function declarations @Override public void exitStringSequenceFunctionDeclaration(StringSequenceFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), new StringSequenceExpression(""))); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), new StringSequenceExpression(""))); } @Override public void exitNumericSequenceFunctionDeclaration(NumericSequenceFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), new NumericSequenceExpression(""))); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), new NumericSequenceExpression(""))); } @Override public void exitBooleanSequenceFunctionDeclaration(BooleanSequenceFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), new BooleanSequenceExpression(""))); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), new BooleanSequenceExpression(""))); } @Override public void exitDateSequenceFunctionDeclaration(DateSequenceFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), new DateSequenceExpression(""))); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), new DateSequenceExpression(""))); } @Override public void exitTimeSequenceFunctionDeclaration(TimeSequenceFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), new TimeSequenceExpression(""))); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), new TimeSequenceExpression(""))); } @Override public void exitDurationSequenceFunctionDeclaration(DurationSequenceFunctionDeclarationContext ctx) { - String functionName = ctx.functionName.getText(); - this.stack.declareFunction(new Function(functionName, new ParsedParameters(), new DurationSequenceExpression(""))); + this.stack.declareFunction(new Function(ctx.functionName.getText(), new ParsedParameters(), new DurationSequenceExpression(""))); } // #endregion Function declarations --------------------------------------- + // #endregion Symbol declarations ------------------------------------------ + // #region Scope management ----------------------------------------------- @Override @@ -3702,7 +3781,7 @@ public void exitLateBoundSequenceFromConcatenatedIterations(LateBoundSequenceFro // #endregion Scope management -------------------------------------------- - // #region Include directive guard ----------------------------------------- + // #region Guards --------------------------------------------------------- @Override public void exitIncludeDirective(IncludeDirectiveContext ctx) { @@ -3710,7 +3789,7 @@ public void exitIncludeDirective(IncludeDirectiveContext ctx) { throw TranslatorConfigurationException.unresolvedIncludeDirective(path); } - // #endregion Include directive guard -------------------------------------- + // #endregion Guards ------------------------------------------------------ } diff --git a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java index 27447d22..19b99d02 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2Test.java @@ -337,7 +337,7 @@ void testTimeComparison_OfTimeReferenceAndTimeFunction() { } @Test - void testDurationComparison_UsingYearMOnthDurationLiterals() { + void testDurationComparison_UsingYearMonthDurationLiterals() { testExpressionTranslationWithContext( "boolean(for $T in (current-date()) return ($T + xs:yearMonthDuration('P1Y') = $T + xs:yearMonthDuration('P12M')))", "BT-00-Text", "P1Y == P12M"); @@ -3201,7 +3201,7 @@ void testScalarFromRepeatableField_ThrowsError() { // A repeatable field used as scalar should throw TypeMismatchException.fieldMayRepeat() TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Repeatable-Text == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); assertTrue(ex.getMessage().startsWith("line "), "Error message should include source position"); } @@ -3210,7 +3210,7 @@ void testScalarFromFieldInRepeatableNode_ThrowsErrorFromRootContext() { // Field in ND-RepeatableNode (repeatable) used as scalar from ND-Root should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-Repeatable-Node == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); } @Test @@ -3225,7 +3225,7 @@ void testScalarFromFieldInNestedRepeatableNode_ThrowsErrorFromRootContext() { // Field in ND-RepeatableSubSubNode (inside ND-NonRepeatableSubNode inside ND-RepeatableNode) used from root should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-RepeatableSubSubNode == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); } @Test @@ -3233,7 +3233,7 @@ void testScalarFromFieldInNestedRepeatableNode_ThrowsErrorFromRepeatableNodeCont // Field in ND-RepeatableSubSubNode used from ND-RepeatableNode should still throw (ND-RepeatableSubSubNode is also repeatable) TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-RepeatableNode", "BT-00-Text-In-RepeatableSubSubNode == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); } @Test @@ -3248,7 +3248,7 @@ void testScalarFromFieldInNonRepeatableNestedInRepeatable_ThrowsErrorFromRootCon // Field in ND-NonRepeatableSubNode (non-repeatable) inside ND-RepeatableNode (repeatable) used from root should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text-In-NonRepeatableSubNode == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); } @Test @@ -3263,7 +3263,7 @@ void testRepeatableFieldInUniqueCondition_ThrowsError() { // A repeatable field used as needle (left side) in uniqueness condition should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Repeatable-Text is unique in /BT-00-Text")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); } // #endregion: Scalar/Sequence Validation @@ -3297,7 +3297,7 @@ void testScalarFromNodeContextVariable_ThrowsNodeCannotBeValue() { // A node context variable used as scalar value should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "for context:$n in ND-SubNode return $n == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_FIELD_CONTEXT, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.NODE_CONTEXT_AS_VALUE, ex.getErrorCode()); } @Test @@ -3305,7 +3305,7 @@ void testSequenceFromNodeContextVariable_ThrowsNodeCannotBeValue() { // A node context variable used in count() should throw TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "for context:$n in ND-SubNode return count($n)")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_FIELD_CONTEXT, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.NODE_CONTEXT_AS_VALUE, ex.getErrorCode()); } // #endregion: TypeMismatchException - nodeCannotBeValue @@ -3321,14 +3321,6 @@ void testScalarFromFieldContextVariable_NonRepeatable_Works() { "for context:$f in BT-00-Text return $f == 'test'"); } - @Test - void testScalarFromFieldContextVariable_Repeatable_ThrowsFieldMayRepeat() { - // A repeatable field context variable used as scalar should throw - TypeMismatchException ex = assertThrows(TypeMismatchException.class, - () -> translateExpressionWithContext("ND-Root", "for context:$f in BT-00-Repeatable-Text return $f == 'test'")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); - } - // #endregion: TypeMismatchException - fieldMayRepeat (Context Variables) // #region: Context Override Syntax Tests ------------------------------------ @@ -3383,7 +3375,7 @@ void testPredicateComparison_RepeatableFieldAsScalar_ThrowsError() { // Pattern: FIELD[REPEATABLE_FIELD == $var] - the repeatable field is used as scalar TypeMismatchException ex = assertThrows(TypeMismatchException.class, () -> translateExpressionWithContext("ND-Root", "BT-00-Text[BT-00-Repeatable-Text == 'test']")); - assertEquals(TypeMismatchException.ErrorCode.EXPECTED_SCALAR, ex.getErrorCode()); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); } @Test diff --git a/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java b/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java index 608406d8..1de236db 100644 --- a/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java +++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java @@ -25,6 +25,7 @@ import eu.europa.ted.efx.EfxTestsBase; import eu.europa.ted.efx.exceptions.InvalidArgumentException; import eu.europa.ted.efx.exceptions.InvalidIndentationException; +import eu.europa.ted.efx.exceptions.TypeMismatchException; import eu.europa.ted.efx.interfaces.IncludedFileResolver; import eu.europa.ted.efx.mock.DependencyFactoryMock; import eu.europa.ted.efx.model.DecimalFormat; @@ -1648,6 +1649,24 @@ void testWithDisplay_RootContext_ForConcatenatedIterations() { "with ND-Root display ${for text:$x in BT-00-Repeatable-Text return BT-00-Repeatable-Text[BT-00-Text == $x]};")); } + @Test + void testWithDisplay_PredicateWithForLoop_VarInRepeatableField() { + // Working variant: $var in repeatable-field inside a sub-predicate. + // Analogous to: OPT-316-Contract[$tender in BT-3202-Contract] + translateTemplate( + "with ND-Root[count(for text:$x in BT-00-Repeatable-Text, text:$y in BT-00-Text[$x in BT-00-Repeatable-Text] return $y) > 0] display foo;"); + } + + @Test + void testWithDisplay_PredicateWithForLoop_RepeatableFieldEqVar() { + // Broken variant: repeatable-field == $var inside a sub-predicate. + // Analogous to: OPT-316-Contract[BT-3202-Contract == $tender] + // This should produce a meaningful error about scalar/multiple mismatch, + // not a confusing syntax error like "expected {When, Display, Invoke}". + assertThrows(TypeMismatchException.class, () -> translateTemplate( + "with BT-00-Number[count(for text:$x in BT-00-Repeatable-Text, text:$y in BT-00-Text[BT-00-Repeatable-Text == $x] return $y) > 0] display foo;")); + } + // #endregion contextDeclarationBlock ---------------------------------------- // #region chooseTemplate ---------------------------------------------------- diff --git a/src/test/java/eu/europa/ted/efx/sdk2/PreprocessorTypeResolutionTest.java b/src/test/java/eu/europa/ted/efx/sdk2/PreprocessorTypeResolutionTest.java new file mode 100644 index 00000000..63daf872 --- /dev/null +++ b/src/test/java/eu/europa/ted/efx/sdk2/PreprocessorTypeResolutionTest.java @@ -0,0 +1,278 @@ +/* + * Copyright 2026 European Union + * + * Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European + * Commission – subsequent versions of the EUPL (the "Licence"); You may not use this work except in + * compliance with the Licence. You may obtain a copy of the Licence at: + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software distributed under the Licence + * is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the Licence for the specific language governing permissions and limitations under + * the Licence. + */ +package eu.europa.ted.efx.sdk2; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import eu.europa.ted.efx.EfxTestsBase; +import eu.europa.ted.efx.exceptions.TypeMismatchException; + +/** + * Tests for the ExpressionPreprocessor's type resolution logic. + * + * Organized by resolve method and CardinalityResolutionContext to clearly show coverage. + * Each test is named: test[ResolveMethod]_[Context]_[scenario]. + */ +class PreprocessorTypeResolutionTest extends EfxTestsBase { + + @Override + protected String getSdkVersion() { + return "eforms-sdk-2.0"; + } + + // #region resolveFieldOrAttributeReference ----------------------------------- + + @Test + void testResolveFieldReference_Scalar_NonRepeatableField() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", "BT-00-Text == 'test'")); + } + + @Test + void testResolveFieldReference_Scalar_RepeatableField_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", "BT-00-Repeatable-Text == 'test'")); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); + } + + @Test + void testResolveFieldReference_Scalar_NonRepeatableAttribute() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", "BT-00-CodeAttribute/@attribute == 'test'")); + } + + @Test + void testResolveFieldReference_Scalar_AttributeOnRepeatableNode_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", + "BT-00-Text-In-Repeatable-Node/@attribute == 'test'")); + assertEquals(TypeMismatchException.ErrorCode.FIELD_MAY_REPEAT, ex.getErrorCode()); + } + + @Test + void testResolveFieldReference_Scalar_AttributeOnRepeatableNode_OkFromSameContext() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-RepeatableNode", + "BT-00-Text-In-Repeatable-Node/@attribute == 'test'")); + } + + @Test + void testResolveFieldReference_Sequence_RepeatableFieldInIterator() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", + "for text:$x in BT-00-Repeatable-Text return $x")); + } + + @Test + void testResolveFieldReference_Sequence_NonRepeatableFieldInIterator() { + // Silent promotion: non-repeatable field accepted in sequence context. + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", + "for text:$x in BT-00-Text return $x")); + } + + @Test + void testResolveFieldReference_Either_RepeatableFieldInForReturn() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", + "for text:$x in BT-00-Text return BT-00-Repeatable-Text")); + } + + @Test + void testResolveFieldReference_Either_NonRepeatableFieldInForReturn() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", + "for text:$x in BT-00-Text return BT-00-Text")); + } + + // #endregion resolveFieldOrAttributeReference -------------------------------- + + // #region resolveFunctionInvocation ------------------------------------------ + + @Test + void testResolveFunctionInvocation_Scalar() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", "number(BT-00-Text)")); + } + + @Test + void testResolveFunctionInvocation_Sequence() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", "count(BT-00-Text)")); + } + + @Test + void testResolveFunctionInvocation_Sequence_ScalarFunction_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateTemplate(lines( + "let text:?f() = 'hi';", + "display count: ${count(?f())};"))); + assertEquals(TypeMismatchException.ErrorCode.IDENTIFIER_IS_SCALAR, ex.getErrorCode()); + } + + @Test + void testResolveFunctionInvocation_Scalar_SequenceFunction_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateTemplate(lines( + "let text*:?f() = ['a', 'b'];", + "with BT-00-Text[?f() == 'a'] display foo;"))); + assertEquals(TypeMismatchException.ErrorCode.IDENTIFIER_IS_SEQUENCE, ex.getErrorCode()); + } + + @Test + void testResolveFunctionInvocation_Either_ScalarFunction() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let text:?f() = 'hi';", + "display ${?f()};"))); + } + + @Test + void testResolveFunctionInvocation_Either_SequenceFunction() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let text*:?f() = ['a', 'b'];", + "display count: ${count(?f())};"))); + } + + // #endregion resolveFunctionInvocation --------------------------------------- + + // #region resolveContextVariableReference ------------------------------------ + + @Test + void testResolveContextVariable_Scalar_NonRepeatableField() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", + "for context:$f in BT-00-Text return $f == 'test'")); + } + + @Test + void testResolveContextVariable_Scalar_RepeatableField() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", + "for context:$f in BT-00-Repeatable-Text return $f == 'test'")); + } + + @Test + void testResolveContextVariable_Scalar_NodeContextAsValue_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", + "for context:$n in ND-SubNode return $n == 'test'")); + assertEquals(TypeMismatchException.ErrorCode.NODE_CONTEXT_AS_VALUE, ex.getErrorCode()); + } + + @Test + void testResolveContextVariable_Sequence_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateExpressionWithContext("ND-Root", + "for context:$f in BT-00-Repeatable-Text return 'test' in $f")); + assertEquals(TypeMismatchException.ErrorCode.IDENTIFIER_IS_SCALAR, ex.getErrorCode()); + } + + @Test + void testResolveContextVariable_Either_FieldContextInForReturn() { + assertDoesNotThrow( + () -> translateExpressionWithContext("ND-Root", + "for context:$f in BT-00-Text return $f")); + } + + // #endregion resolveContextVariableReference --------------------------------- + + // #region resolveRegularVariableReference ------------------------------------ + + @Test + void testResolveRegularVariable_Scalar_ScalarVariable() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let text:$x = 'a';", + "with BT-00-Text[$x == 'a'] display foo;"))); + } + + @Test + void testResolveRegularVariable_Scalar_SequenceVariable_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateTemplate(lines( + "let text*:$items = ['a', 'b'];", + "with BT-00-Text[$items == 'a'] display foo;"))); + assertEquals(TypeMismatchException.ErrorCode.IDENTIFIER_IS_SEQUENCE, ex.getErrorCode()); + } + + @Test + void testResolveRegularVariable_Sequence_ScalarVariable_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateTemplate(lines( + "let text:$x = 'a';", + "display count: ${count($x)};"))); + assertEquals(TypeMismatchException.ErrorCode.IDENTIFIER_IS_SCALAR, ex.getErrorCode()); + } + + @Test + void testResolveRegularVariable_Sequence_SequenceVariable() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let text*:$items = ['a', 'b', 'c'];", + "display count: ${count($items)};"))); + } + + @Test + void testResolveRegularVariable_Either_ScalarVariable() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let text:$x = 'test';", + "display value: ${$x};"))); + } + + @Test + void testResolveRegularVariable_Either_SequenceVariable() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let text*:$items = ['a', 'b'];", + "display ${for text:$x in $items return $x};"))); + } + + // #endregion resolveRegularVariableReference --------------------------------- + + // #region resolveDictionaryLookup -------------------------------------------- + + @Test + void testResolveDictionaryLookup_Scalar_InPredicate() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let $dic index BT-00-Number by BT-00-Text;", + "with BT-00-Text[$dic['key'] == 1] display foo;"))); + } + + @Test + void testResolveDictionaryLookup_Either_InDisplayBlock() { + assertDoesNotThrow( + () -> translateTemplate(lines( + "let $dic index BT-00-Number by BT-00-Text;", + "display ${$dic['key']};"))); + } + + @Test + void testResolveDictionaryLookup_Sequence_Throws() { + TypeMismatchException ex = assertThrows(TypeMismatchException.class, + () -> translateTemplate(lines( + "let $dic index BT-00-Number by BT-00-Text;", + "display ${count($dic['key'])};"))); + assertEquals(TypeMismatchException.ErrorCode.DICTIONARY_IS_SCALAR, ex.getErrorCode()); + } + + // #endregion resolveDictionaryLookup ----------------------------------------- +}