diff --git a/src/main/java/eu/europa/ted/efx/EfxTranslatorOptions.java b/src/main/java/eu/europa/ted/efx/EfxTranslatorOptions.java
index 3c62202..3bc6f2e 100644
--- a/src/main/java/eu/europa/ted/efx/EfxTranslatorOptions.java
+++ b/src/main/java/eu/europa/ted/efx/EfxTranslatorOptions.java
@@ -6,6 +6,7 @@
import java.util.List;
import java.util.Locale;
+import eu.europa.ted.efx.interfaces.IncludedFileResolver;
import eu.europa.ted.efx.interfaces.TranslatorOptions;
import eu.europa.ted.efx.model.DecimalFormat;
@@ -42,6 +43,7 @@ public class EfxTranslatorOptions implements TranslatorOptions {
private final String userDefinedFunctionNamespace;
private final boolean profilerEnabled;
private final Path profilerOutputPath;
+ private final IncludedFileResolver includedFileResolver;
public EfxTranslatorOptions(DecimalFormat symbols) {
this(DEFAULT_PROFILER_ENABLED, DEFAULT_PROFILER_OUTPUT_PATH, DEFAULT_UDF_NAMESPACE, symbols, Locale.ENGLISH);
@@ -68,11 +70,16 @@ public EfxTranslatorOptions(String udfNamespace, DecimalFormat symbols, Locale p
}
public EfxTranslatorOptions(boolean profilerEnabled, Path profilerOutputPath, String udfNamespace, DecimalFormat symbols, Locale primaryLocale, Locale... otherLocales) {
+ this(profilerEnabled, profilerOutputPath, udfNamespace, symbols, null, primaryLocale, otherLocales);
+ }
+
+ public EfxTranslatorOptions(boolean profilerEnabled, Path profilerOutputPath, String udfNamespace, DecimalFormat symbols, IncludedFileResolver includedFileResolver, Locale primaryLocale, Locale... otherLocales) {
this.userDefinedFunctionNamespace = udfNamespace;
this.symbols = symbols;
this.primaryLocale = primaryLocale;
this.profilerEnabled = profilerEnabled;
this.profilerOutputPath = profilerOutputPath;
+ this.includedFileResolver = includedFileResolver;
this.otherLocales = new ArrayList<>(Arrays.asList(otherLocales));
}
@@ -130,4 +137,9 @@ public boolean isProfilerEnabled() {
public Path getProfilerOutputPath() {
return this.profilerOutputPath;
}
+
+ @Override
+ public IncludedFileResolver getIncludedFileResolver() {
+ return this.includedFileResolver;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java b/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java
index 5b99cf4..16e2303 100644
--- a/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java
+++ b/src/main/java/eu/europa/ted/efx/exceptions/InvalidUsageException.java
@@ -29,7 +29,8 @@ public enum ErrorCode {
INVALID_NOTICE_SUBTYPE_TOKEN,
FIELD_NOT_WITHHOLDABLE,
TEMPLATE_ONLY_FUNCTION,
- UNSUPPORTED_REGEX_CONSTRUCT
+ UNSUPPORTED_REGEX_CONSTRUCT,
+ CIRCULAR_INCLUDE
}
private static final String SHORTHAND_REQUIRES_CODE_OR_INDICATOR = "Indirect label reference shorthand #{%1$s}, requires a field of type 'code' or 'indicator'. Field %1$s is of type %2$s.";
@@ -39,6 +40,7 @@ public enum ErrorCode {
private static final String FIELD_NOT_WITHHOLDABLE = "Field '%s' is always published and cannot be withheld from publication.";
private static final String TEMPLATE_ONLY_FUNCTION = "Function '%s' can only be used in templates, not in expressions or validation rules.";
private static final String UNSUPPORTED_REGEX_CONSTRUCT = "Invalid regex pattern %s at position %d: %s";
+ private static final String CIRCULAR_INCLUDE = "Circular #include detected: '%s'.";
private final ErrorCode errorCode;
@@ -83,4 +85,8 @@ public static InvalidUsageException templateOnlyFunction(ParserRuleContext ctx,
public static InvalidUsageException unsupportedRegexConstruct(String pattern, int position, String reason) {
return new InvalidUsageException(ErrorCode.UNSUPPORTED_REGEX_CONSTRUCT, UNSUPPORTED_REGEX_CONSTRUCT, pattern, position, reason);
}
+
+ public static InvalidUsageException circularInclude(String path) {
+ return new InvalidUsageException(ErrorCode.CIRCULAR_INCLUDE, CIRCULAR_INCLUDE, path);
+ }
}
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 e480711..e3e5228 100644
--- a/src/main/java/eu/europa/ted/efx/exceptions/TranslatorConfigurationException.java
+++ b/src/main/java/eu/europa/ted/efx/exceptions/TranslatorConfigurationException.java
@@ -29,7 +29,9 @@ public enum ErrorCode {
UNHANDLED_VARIABLE_CONTEXT,
UNHANDLED_PRIVACY_SETTING,
UNHANDLED_LINKED_FIELD_PROPERTY,
- UNHANDLED_PREDICATE_CONTEXT
+ UNHANDLED_PREDICATE_CONTEXT,
+ INCLUDE_RESOLVER_NOT_CONFIGURED,
+ UNRESOLVED_INCLUDE_DIRECTIVE
}
private static final String TYPE_NOT_REGISTERED =
@@ -78,6 +80,14 @@ public enum ErrorCode {
"If the grammar was updated to allow predicates in new contexts, " +
"add a handler for this case in enterPredicate().";
+ private static final String INCLUDE_RESOLVER_NOT_CONFIGURED =
+ "EFX rules contain #include directives but no IncludedFileResolver is configured. " +
+ "Pass an IncludedFileResolver via TranslatorOptions to enable include resolution.";
+
+ private static final String UNRESOLVED_INCLUDE_DIRECTIVE =
+ "Unresolved #include directive '%s' found during preprocessing. " +
+ "Include resolution may have been skipped or failed silently.";
+
private final ErrorCode errorCode;
private TranslatorConfigurationException(ErrorCode errorCode, String template, Object... args) {
@@ -124,4 +134,12 @@ public static TranslatorConfigurationException unhandledLinkedFieldProperty(Stri
public static TranslatorConfigurationException unhandledPredicateContext(String contextClassName) {
return new TranslatorConfigurationException(ErrorCode.UNHANDLED_PREDICATE_CONTEXT, UNHANDLED_PREDICATE_CONTEXT, contextClassName);
}
+
+ public static TranslatorConfigurationException includeResolverNotConfigured() {
+ return new TranslatorConfigurationException(ErrorCode.INCLUDE_RESOLVER_NOT_CONFIGURED, INCLUDE_RESOLVER_NOT_CONFIGURED);
+ }
+
+ public static TranslatorConfigurationException unresolvedIncludeDirective(String path) {
+ return new TranslatorConfigurationException(ErrorCode.UNRESOLVED_INCLUDE_DIRECTIVE, UNRESOLVED_INCLUDE_DIRECTIVE, path);
+ }
}
diff --git a/src/main/java/eu/europa/ted/efx/interfaces/IncludedFileResolver.java b/src/main/java/eu/europa/ted/efx/interfaces/IncludedFileResolver.java
new file mode 100644
index 0000000..b543342
--- /dev/null
+++ b/src/main/java/eu/europa/ted/efx/interfaces/IncludedFileResolver.java
@@ -0,0 +1,25 @@
+package eu.europa.ted.efx.interfaces;
+
+import java.io.IOException;
+
+/**
+ * Resolves {@code #include} directive paths to their text content.
+ *
+ *
+ * Implementations determine how include paths are mapped to file contents. For example, a
+ * file-system resolver may resolve paths relative to a base directory, while a database-backed
+ * resolver may look up the content by name.
+ *
+ */
+@FunctionalInterface
+public interface IncludedFileResolver {
+
+ /**
+ * Resolves the given include path and returns the text content of the included file.
+ *
+ * @param path The include path as specified in the {@code #include} directive (without quotes).
+ * @return The text content of the included file.
+ * @throws IOException If the path cannot be resolved or the content cannot be read.
+ */
+ String resolve(String path) throws IOException;
+}
diff --git a/src/main/java/eu/europa/ted/efx/interfaces/TranslatorOptions.java b/src/main/java/eu/europa/ted/efx/interfaces/TranslatorOptions.java
index 5be3311..fe182db 100644
--- a/src/main/java/eu/europa/ted/efx/interfaces/TranslatorOptions.java
+++ b/src/main/java/eu/europa/ted/efx/interfaces/TranslatorOptions.java
@@ -26,8 +26,35 @@ public interface TranslatorOptions {
/**
* Returns the output path for EFX profiling results.
- *
+ *
* @return Path where profiling results should be written, or null if no file output is desired
*/
public Path getProfilerOutputPath();
+
+ /**
+ * Returns the include resolver for resolving {@code #include} directives in rules files.
+ *
+ * @return The include resolver, or null if include resolution is not configured.
+ */
+ default IncludedFileResolver getIncludedFileResolver() {
+ return null;
+ }
+
+ /**
+ * Returns a new {@link TranslatorOptions} that delegates all methods to this instance
+ * but overrides the {@link IncludedFileResolver}.
+ */
+ static TranslatorOptions withResolver(TranslatorOptions delegate, IncludedFileResolver resolver) {
+ return new TranslatorOptions() {
+ @Override public DecimalFormat getDecimalFormat() { return delegate.getDecimalFormat(); }
+ @Override public String getPrimaryLanguage2LetterCode() { return delegate.getPrimaryLanguage2LetterCode(); }
+ @Override public String getPrimaryLanguage3LetterCode() { return delegate.getPrimaryLanguage3LetterCode(); }
+ @Override public String[] getAllLanguage2LetterCodes() { return delegate.getAllLanguage2LetterCodes(); }
+ @Override public String[] getAllLanguage3LetterCodes() { return delegate.getAllLanguage3LetterCodes(); }
+ @Override public String getUserDefinedFunctionNamespace() { return delegate.getUserDefinedFunctionNamespace(); }
+ @Override public boolean isProfilerEnabled() { return delegate.isProfilerEnabled(); }
+ @Override public Path getProfilerOutputPath() { return delegate.getProfilerOutputPath(); }
+ @Override public IncludedFileResolver getIncludedFileResolver() { return resolver; }
+ };
+ }
}
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 128b90a..873c649 100644
--- a/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java
+++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxExpressionTranslatorV2.java
@@ -3697,6 +3697,16 @@ public void exitLateBoundSequenceFromConcatenatedIterations(LateBoundSequenceFro
// #endregion Scope management --------------------------------------------
+ // #region Include directive guard -----------------------------------------
+
+ @Override
+ public void exitIncludeDirective(IncludeDirectiveContext ctx) {
+ String path = ctx.IncludePath() != null ? ctx.IncludePath().getText().trim() : "";
+ throw TranslatorConfigurationException.unresolvedIncludeDirective(path);
+ }
+
+ // #endregion Include directive guard --------------------------------------
+
}
// #endregion Pre-processing ------------------------------------------------
diff --git a/src/main/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2.java b/src/main/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2.java
index f117be9..c092d1a 100644
--- a/src/main/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2.java
+++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2.java
@@ -15,6 +15,7 @@
import java.io.IOException;
import java.io.InputStream;
+import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.Map;
@@ -22,6 +23,7 @@
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.misc.ParseCancellationException;
import org.antlr.v4.runtime.tree.ParseTree;
import org.antlr.v4.runtime.tree.ParseTreeWalker;
import org.slf4j.Logger;
@@ -30,6 +32,7 @@
import eu.europa.ted.eforms.sdk.component.SdkComponent;
import eu.europa.ted.eforms.sdk.component.SdkComponentType;
import eu.europa.ted.efx.interfaces.EfxRulesTranslator;
+import eu.europa.ted.efx.interfaces.IncludedFileResolver;
import eu.europa.ted.efx.interfaces.ScriptGenerator;
import eu.europa.ted.efx.interfaces.SymbolResolver;
import eu.europa.ted.efx.interfaces.TranslatorOptions;
@@ -113,6 +116,13 @@ public Map translateRules(Path pathname, TranslatorOptions optio
throws IOException {
logger.debug("Translating EFX rules from file: {}", pathname);
CharStream input = CharStreams.fromPath(pathname);
+
+ // Default to filesystem-based include resolution relative to the input file
+ if (options.getIncludedFileResolver() == null) {
+ Path baseDir = pathname.toAbsolutePath().getParent();
+ options = TranslatorOptions.withResolver(options, new FileSystemIncludedFileResolver(baseDir));
+ }
+
return translateRulesFromCharStream(input, options);
}
@@ -123,8 +133,7 @@ public Map translateRules(String rules, TranslatorOptions option
try {
return translateRulesFromCharStream(input, options);
} catch (IOException e) {
- // This should never happen when reading from a string
- throw new RuntimeException("Unexpected IOException while translating rules from string", e);
+ throw new UncheckedIOException("Include resolution failed during rules translation", e);
}
}
@@ -149,7 +158,7 @@ private Map translateRulesFromCharStream(CharStream input,
logger.debug("Parsing EFX rules");
// New in EFX-2: rules preprocessing
- final RulesPreprocessor preprocessor = this.new RulesPreprocessor(input);
+ final RulesPreprocessor preprocessor = this.new RulesPreprocessor(input, options.getIncludedFileResolver());
final String preprocessedRules = preprocessor.processRules();
// Now parse the preprocessed rules
@@ -176,6 +185,11 @@ private Map translateRulesFromCharStream(CharStream input,
ParseTreeWalker walker = new ParseTreeWalker();
walker.walk(this, tree);
+ if (this.completeValidation.getStages().isEmpty()) {
+ throw new ParseCancellationException(
+ "Rules file must contain at least one validation stage");
+ }
+
// Generate output using the validator generator
return this.validatorGenerator.generateOutput(this.completeValidation);
}
@@ -649,8 +663,9 @@ public void exitFallbackRule(FallbackRuleContext ctx) {
*/
class RulesPreprocessor extends ExpressionPreprocessor {
- RulesPreprocessor(final CharStream charStream) {
- super(charStream);
+ RulesPreprocessor(final CharStream charStream, IncludedFileResolver resolver)
+ throws IOException {
+ super(new IncludeProcessor(resolver).resolve(charStream));
}
String processRules() {
diff --git a/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java b/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java
index b1578a0..71cc4f9 100644
--- a/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java
+++ b/src/main/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2.java
@@ -15,6 +15,7 @@
import java.io.IOException;
import java.io.InputStream;
+import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
@@ -42,6 +43,7 @@
import eu.europa.ted.efx.exceptions.InvalidIndentationException;
import eu.europa.ted.efx.interfaces.Argument;
import eu.europa.ted.efx.interfaces.EfxTemplateTranslator;
+import eu.europa.ted.efx.interfaces.IncludedFileResolver;
import eu.europa.ted.efx.interfaces.MarkupGenerator;
import eu.europa.ted.efx.interfaces.ScriptGenerator;
import eu.europa.ted.efx.interfaces.SymbolResolver;
@@ -165,6 +167,12 @@ public EfxTemplateTranslatorV2(final MarkupGenerator markupGenerator,
*/
@Override
public String renderTemplate(final Path pathname, TranslatorOptions options) throws IOException {
+ // Default to filesystem-based include resolution relative to the input file
+ if (options.getIncludedFileResolver() == null) {
+ Path baseDir = pathname.toAbsolutePath().getParent();
+ options = TranslatorOptions.withResolver(options, new FileSystemIncludedFileResolver(baseDir));
+ }
+
return renderTemplate(CharStreams.fromPath(pathname), options);
}
@@ -173,7 +181,11 @@ public String renderTemplate(final Path pathname, TranslatorOptions options) thr
*/
@Override
public String renderTemplate(final String template, TranslatorOptions options) {
- return renderTemplate(CharStreams.fromString(template), options);
+ try {
+ return renderTemplate(CharStreams.fromString(template), options);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Include resolution failed during template rendering", e);
+ }
}
@Override
@@ -181,13 +193,14 @@ public String renderTemplate(final InputStream stream, TranslatorOptions options
return renderTemplate(CharStreams.fromStream(stream), options);
}
- private String renderTemplate(final CharStream charStream, TranslatorOptions options) {
+ private String renderTemplate(final CharStream charStream, TranslatorOptions options)
+ throws IOException {
logger.debug("Rendering template");
final long startTime = System.currentTimeMillis();
// New in EFX-2: template preprocessing
final long preprocessingStartTime = System.currentTimeMillis();
- final TemplatePreprocessor preprocessor = this.new TemplatePreprocessor(charStream);
+ final TemplatePreprocessor preprocessor = this.new TemplatePreprocessor(charStream, options.getIncludedFileResolver());
final String preprocessedTemplate = preprocessor.processTemplate();
final long preprocessingEndTime = System.currentTimeMillis();
final long preprocessingDuration = preprocessingEndTime - preprocessingStartTime;
@@ -1518,8 +1531,8 @@ private int getIndentLevel(IndentationContext ctx) {
*/
class TemplatePreprocessor extends ExpressionPreprocessor {
- TemplatePreprocessor(CharStream template) {
- super(template);
+ TemplatePreprocessor(CharStream template, IncludedFileResolver resolver) throws IOException {
+ super(new IncludeProcessor(resolver).resolve(template));
}
String processTemplate() {
diff --git a/src/main/java/eu/europa/ted/efx/sdk2/FileSystemIncludedFileResolver.java b/src/main/java/eu/europa/ted/efx/sdk2/FileSystemIncludedFileResolver.java
new file mode 100644
index 0000000..3b41000
--- /dev/null
+++ b/src/main/java/eu/europa/ted/efx/sdk2/FileSystemIncludedFileResolver.java
@@ -0,0 +1,40 @@
+package eu.europa.ted.efx.sdk2;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import eu.europa.ted.efx.interfaces.IncludedFileResolver;
+
+/**
+ * Resolves include paths relative to a base directory on the file system.
+ *
+ *
+ * This is the default resolver used when translating rules from a file path. Include paths
+ * specified in {@code #include} directives are resolved relative to the base directory.
+ *
+ */
+public class FileSystemIncludedFileResolver implements IncludedFileResolver {
+
+ private final Path baseDir;
+
+ public FileSystemIncludedFileResolver(Path baseDir) throws IOException {
+ this.baseDir = baseDir.toRealPath();
+ }
+
+ @Override
+ public String resolve(String path) throws IOException {
+ Path normalized = this.baseDir.resolve(path).normalize();
+ if (!normalized.startsWith(this.baseDir)) {
+ throw new IOException(
+ "Include path '" + path + "' resolves outside the base directory");
+ }
+ // Resolve symlinks to prevent symlink-based traversal
+ Path real = normalized.toRealPath();
+ if (!real.startsWith(this.baseDir)) {
+ throw new IOException(
+ "Include path '" + path + "' resolves outside the base directory");
+ }
+ return Files.readString(real);
+ }
+}
diff --git a/src/main/java/eu/europa/ted/efx/sdk2/IncludeProcessor.java b/src/main/java/eu/europa/ted/efx/sdk2/IncludeProcessor.java
new file mode 100644
index 0000000..1ccd2f8
--- /dev/null
+++ b/src/main/java/eu/europa/ted/efx/sdk2/IncludeProcessor.java
@@ -0,0 +1,95 @@
+package eu.europa.ted.efx.sdk2;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.antlr.v4.runtime.CharStream;
+import org.antlr.v4.runtime.CharStreams;
+
+import eu.europa.ted.efx.exceptions.InvalidUsageException;
+import eu.europa.ted.efx.exceptions.TranslatorConfigurationException;
+import eu.europa.ted.efx.interfaces.IncludedFileResolver;
+
+/**
+ * Processes {@code #include} directives in EFX rules text by performing text substitution.
+ *
+ *
+ * This processor runs before any ANTLR parsing. It scans the input text for {@code #include "path"}
+ * lines, resolves them using the provided {@link IncludedFileResolver}, and substitutes each directive
+ * with the resolved content. Recursive includes are supported with circular dependency detection.
+ *
+ */
+public class IncludeProcessor {
+
+ private static final Pattern INCLUDE_PATTERN =
+ Pattern.compile("^[ \\t]*#include[ \\t]+\"([^\"]+)\"[ \\t]*(?://[^\\r\\n]*)?[ \\t]*$",
+ Pattern.MULTILINE);
+
+ private final IncludedFileResolver resolver;
+ private final Set resolving = new HashSet<>();
+
+ public IncludeProcessor(IncludedFileResolver resolver) {
+ this.resolver = resolver;
+ }
+
+ /**
+ * Resolves all {@code #include} directives in the given CharStream.
+ *
+ * @param input The CharStream potentially containing include directives.
+ * @return A CharStream with all includes substituted recursively.
+ * @throws IOException If an included file cannot be resolved.
+ */
+ public CharStream resolve(CharStream input) throws IOException {
+ String resolvedText = this.process(input.toString());
+ return CharStreams.fromString(resolvedText);
+ }
+
+ /**
+ * Resolves all {@code #include} directives in the given text.
+ *
+ * @param text The EFX rules text potentially containing include directives.
+ * @return The text with all includes substituted recursively.
+ * @throws IOException If an included file cannot be resolved.
+ */
+ public String process(String text) throws IOException {
+ if (this.resolver == null) {
+ if (INCLUDE_PATTERN.matcher(text).find()) {
+ throw TranslatorConfigurationException.includeResolverNotConfigured();
+ }
+ return text;
+ }
+
+ StringBuilder result = new StringBuilder();
+ Matcher matcher = INCLUDE_PATTERN.matcher(text);
+ int lastEnd = 0;
+
+ while (matcher.find()) {
+ result.append(text, lastEnd, matcher.start());
+
+ String path = matcher.group(1);
+
+ if (!this.resolving.add(path)) {
+ throw InvalidUsageException.circularInclude(path);
+ }
+
+ try {
+ String included = this.resolver.resolve(path);
+ String processed = this.process(included);
+ result.append(processed);
+ if (!processed.isEmpty() && !processed.endsWith("\n")) {
+ result.append("\n");
+ }
+ } finally {
+ this.resolving.remove(path);
+ }
+
+ lastEnd = matcher.end();
+ }
+
+ result.append(text, lastEnd, text.length());
+ return result.toString();
+ }
+}
diff --git a/src/test/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test.java b/src/test/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test.java
index ed21e72..baadc3b 100644
--- a/src/test/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test.java
+++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test.java
@@ -21,7 +21,10 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
+import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Map;
import javax.xml.parsers.DocumentBuilder;
@@ -31,9 +34,11 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import eu.europa.ted.efx.EfxTestsBase;
import eu.europa.ted.efx.EfxTranslatorOptions;
import eu.europa.ted.efx.exceptions.ThrowingErrorListener;
+import eu.europa.ted.efx.interfaces.IncludedFileResolver;
import eu.europa.ted.efx.mock.DependencyFactoryMock;
import eu.europa.ted.efx.model.DecimalFormat;
import eu.europa.ted.eforms.sdk.schematron.SchematronGenerator;
@@ -559,4 +564,83 @@ void testContextVariable_RepeatableField_UsedAsScalar() throws IOException {
}
//#endregion Context variable type tests
+
+ //#region Include directive tests
+
+ /**
+ * Verifies that a rules file with #include produces the same output
+ * as the equivalent rules file with all content inlined.
+ */
+ @Test
+ void testInclude_SingleFile_SameOutputAsInlined() throws IOException {
+ String testName = "testInclude_SingleFile_SameOutputAsInlined";
+
+ IncludedFileResolver resolver = path -> readExpected(testName, path);
+ EfxTranslatorOptions options = new EfxTranslatorOptions(
+ false, null, EfxTranslatorOptions.DEFAULT_UDF_NAMESPACE,
+ DecimalFormat.XSL_DEFAULT, resolver, java.util.Locale.ENGLISH);
+
+ Map outputFiles = translator.translateRules(readInput(testName), options);
+
+ assertEquals(7, outputFiles.size(), "Should generate exactly 7 files");
+ assertAllOutputs(testName, outputFiles);
+ }
+
+ /**
+ * Verifies that translateRules(Path) auto-wires a filesystem resolver
+ * when no resolver is provided in options.
+ */
+ @Test
+ void testInclude_FromPath_DefaultResolverWorks(@TempDir Path tempDir) throws IOException {
+ String mainContent =
+ "---- STAGE 1a ----\n\n"
+ + "WITH BT-00-Text\n"
+ + " REPORT empty(BT-00-Text)\n"
+ + " AS WARNING R-K7P-M2Q\n"
+ + " FOR BT-00-Text IN 1\n\n"
+ + "#include \"extra.efx\"\n";
+
+ String extraContent =
+ "---- STAGE 1b ----\n\n"
+ + "WITH BT-00-Text\n"
+ + " ASSERT BT-00-Text is present\n"
+ + " AS ERROR R-X3F-N8W\n"
+ + " FOR BT-00-Text IN 1\n";
+
+ Files.writeString(tempDir.resolve("rules.efx"), mainContent);
+ Files.writeString(tempDir.resolve("extra.efx"), extraContent);
+
+ Map outputFiles =
+ translator.translateRules(tempDir.resolve("rules.efx"), EfxTranslatorOptions.DEFAULT);
+
+ assertEquals(7, outputFiles.size(), "Should generate exactly 7 files");
+ for (Map.Entry entry : outputFiles.entrySet()) {
+ if (entry.getKey().endsWith(".sch")) {
+ assertValidXml(entry.getValue(), entry.getKey());
+ }
+ }
+ }
+
+ /**
+ * Verifies that translateRules(String, options) propagates include IO failures
+ * as UncheckedIOException with meaningful context.
+ */
+ @Test
+ void testInclude_FromString_IOFailurePropagates() {
+ String input = "#include \"missing.efx\"\n---- STAGE 1a ----\n";
+
+ IncludedFileResolver resolver = path -> {
+ throw new IOException("File not found: " + path);
+ };
+
+ EfxTranslatorOptions options = new EfxTranslatorOptions(
+ false, null, EfxTranslatorOptions.DEFAULT_UDF_NAMESPACE,
+ DecimalFormat.XSL_DEFAULT, resolver, java.util.Locale.ENGLISH);
+
+ UncheckedIOException thrown = assertThrows(UncheckedIOException.class,
+ () -> translator.translateRules(input, options));
+ assertTrue(thrown.getMessage().contains("Include resolution failed"));
+ }
+
+ //#endregion Include directive tests
}
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 f987e28..608406d 100644
--- a/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java
+++ b/src/test/java/eu/europa/ted/efx/sdk2/EfxTemplateTranslatorV2Test.java
@@ -17,11 +17,17 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.util.Locale;
import org.antlr.v4.runtime.misc.ParseCancellationException;
import org.junit.jupiter.api.Test;
+import eu.europa.ted.efx.EfxTranslator;
+import eu.europa.ted.efx.EfxTranslatorOptions;
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.interfaces.IncludedFileResolver;
+import eu.europa.ted.efx.mock.DependencyFactoryMock;
+import eu.europa.ted.efx.model.DecimalFormat;
class EfxTemplateTranslatorV2Test extends EfxTestsBase {
@Override
@@ -2000,4 +2006,40 @@ void testContextualizer_WithFieldInPredicate() {
"{ND-SubNode[BT-01-SubNode-Text is present]} line1",
" {BT-01-SubNode-Text} line2")));
}
+
+ // #region Include directive ---------------------------------------------------
+
+ @Test
+ void testInclude_SingleFile_SameOutputAsInlined() throws Exception {
+ String includedContent = "{BT-00-Code} Code: ${BT-00-Code}\n";
+
+ String templateWithInclude = lines(
+ "{BT-00-Text} Text: ${BT-00-Text}",
+ "#include \"extra-lines.efx\"") + "\n";
+
+ String templateInlined = lines(
+ "{BT-00-Text} Text: ${BT-00-Text}",
+ "{BT-00-Code} Code: ${BT-00-Code}") + "\n";
+
+ IncludedFileResolver resolver = path -> {
+ if ("extra-lines.efx".equals(path)) {
+ return includedContent;
+ }
+ throw new java.io.IOException("Unknown include: " + path);
+ };
+
+ EfxTranslatorOptions options = new EfxTranslatorOptions(
+ false, null, "udf", DecimalFormat.EFX_DEFAULT, resolver, Locale.ENGLISH);
+
+ String withInclude = EfxTranslator.translateTemplate(
+ DependencyFactoryMock.INSTANCE, getSdkVersion(), templateWithInclude, options);
+ String inlined = translateTemplate(lines(
+ "{BT-00-Text} Text: ${BT-00-Text}",
+ "{BT-00-Code} Code: ${BT-00-Code}"));
+
+ assertEquals(inlined, withInclude,
+ "Template with #include should produce the same output as the equivalent inlined template");
+ }
+
+ // #endregion Include directive ------------------------------------------------
}
\ No newline at end of file
diff --git a/src/test/java/eu/europa/ted/efx/sdk2/FileSystemIncludedFileResolverTest.java b/src/test/java/eu/europa/ted/efx/sdk2/FileSystemIncludedFileResolverTest.java
new file mode 100644
index 0000000..1b3259d
--- /dev/null
+++ b/src/test/java/eu/europa/ted/efx/sdk2/FileSystemIncludedFileResolverTest.java
@@ -0,0 +1,63 @@
+package eu.europa.ted.efx.sdk2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class FileSystemIncludedFileResolverTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void testResolve_ValidPath_ReturnsContent() throws IOException {
+ Files.writeString(tempDir.resolve("rules.efx"), "---- STAGE 1a ----\n");
+
+ FileSystemIncludedFileResolver resolver = new FileSystemIncludedFileResolver(tempDir);
+ String content = resolver.resolve("rules.efx");
+
+ assertEquals("---- STAGE 1a ----\n", content);
+ }
+
+ @Test
+ void testResolve_Subdirectory_ReturnsContent() throws IOException {
+ Path subDir = tempDir.resolve("sub");
+ Files.createDirectories(subDir);
+ Files.writeString(subDir.resolve("extra.efx"), "// extra\n");
+
+ FileSystemIncludedFileResolver resolver = new FileSystemIncludedFileResolver(tempDir);
+ String content = resolver.resolve("sub/extra.efx");
+
+ assertEquals("// extra\n", content);
+ }
+
+ @Test
+ void testResolve_PathTraversal_ThrowsIOException() throws IOException {
+ // Create a file outside the base directory
+ Path outsideDir = tempDir.resolve("outside");
+ Files.createDirectories(outsideDir);
+ Files.writeString(outsideDir.resolve("secret.efx"), "secret");
+
+ // Resolver rooted at a subdirectory
+ Path baseDir = tempDir.resolve("project");
+ Files.createDirectories(baseDir);
+
+ FileSystemIncludedFileResolver resolver = new FileSystemIncludedFileResolver(baseDir);
+ IOException thrown = assertThrows(IOException.class,
+ () -> resolver.resolve("../outside/secret.efx"));
+ assertTrue(thrown.getMessage().contains("outside the base directory"));
+ }
+
+ @Test
+ void testResolve_MissingFile_ThrowsIOException() throws IOException {
+ FileSystemIncludedFileResolver resolver = new FileSystemIncludedFileResolver(tempDir);
+ assertThrows(IOException.class, () -> resolver.resolve("nonexistent.efx"));
+ }
+}
diff --git a/src/test/java/eu/europa/ted/efx/sdk2/IncludeProcessorTest.java b/src/test/java/eu/europa/ted/efx/sdk2/IncludeProcessorTest.java
new file mode 100644
index 0000000..4abad19
--- /dev/null
+++ b/src/test/java/eu/europa/ted/efx/sdk2/IncludeProcessorTest.java
@@ -0,0 +1,150 @@
+package eu.europa.ted.efx.sdk2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import eu.europa.ted.efx.exceptions.InvalidUsageException;
+import eu.europa.ted.efx.exceptions.TranslatorConfigurationException;
+import eu.europa.ted.efx.interfaces.IncludedFileResolver;
+
+class IncludeProcessorTest {
+
+ /**
+ * Simple in-memory resolver backed by a map.
+ */
+ private static IncludedFileResolver mapResolver(Map files) {
+ return path -> {
+ String content = files.get(path);
+ if (content == null) {
+ throw new IOException("File not found: " + path);
+ }
+ return content;
+ };
+ }
+
+ @Test
+ void testNoIncludes_PassesThrough() throws IOException {
+ String input = "---- STAGE 1a ----\nWITH ND-Root\n";
+ IncludeProcessor processor = new IncludeProcessor(null);
+ assertEquals(input, processor.process(input));
+ }
+
+ @Test
+ void testNoResolver_WithInclude_ThrowsError() {
+ String input = "#include \"extra-rules.efx\"\n---- STAGE 1a ----\n";
+ IncludeProcessor processor = new IncludeProcessor(null);
+ assertThrows(TranslatorConfigurationException.class, () -> processor.process(input));
+ }
+
+ @Test
+ void testSingleInclude_SubstitutesContent() throws IOException {
+ Map files = new HashMap<>();
+ files.put("extra.efx", "---- STAGE 2a ----\n");
+
+ String input = "---- STAGE 1a ----\n#include \"extra.efx\"\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ String result = processor.process(input);
+
+ assertEquals("---- STAGE 1a ----\n---- STAGE 2a ----\n\n", result);
+ }
+
+ @Test
+ void testMultipleIncludes_SubstitutesAll() throws IOException {
+ Map files = new HashMap<>();
+ files.put("a.efx", "// content A\n");
+ files.put("b.efx", "// content B\n");
+
+ String input = "#include \"a.efx\"\n#include \"b.efx\"\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ String result = processor.process(input);
+
+ assertEquals("// content A\n\n// content B\n\n", result);
+ }
+
+ @Test
+ void testRecursiveIncludes_ResolvesTransitively() throws IOException {
+ Map files = new HashMap<>();
+ files.put("a.efx", "#include \"b.efx\"\n// from A\n");
+ files.put("b.efx", "// from B\n");
+
+ String input = "#include \"a.efx\"\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ String result = processor.process(input);
+
+ assertEquals("// from B\n\n// from A\n\n", result);
+ }
+
+ @Test
+ void testCircularInclude_ThrowsError() {
+ Map files = new HashMap<>();
+ files.put("a.efx", "#include \"b.efx\"\n");
+ files.put("b.efx", "#include \"a.efx\"\n");
+
+ String input = "#include \"a.efx\"\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ assertThrows(InvalidUsageException.class, () -> processor.process(input));
+ }
+
+ @Test
+ void testSelfInclude_ThrowsError() {
+ Map files = new HashMap<>();
+ files.put("self.efx", "#include \"self.efx\"\n");
+
+ String input = "#include \"self.efx\"\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ assertThrows(InvalidUsageException.class, () -> processor.process(input));
+ }
+
+ @Test
+ void testIncludeWithComment_IsRecognized() throws IOException {
+ Map files = new HashMap<>();
+ files.put("extra.efx", "// included\n");
+
+ String input = "#include \"extra.efx\" // hand-written rules\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ String result = processor.process(input);
+
+ assertEquals("// included\n\n", result);
+ }
+
+ @Test
+ void testIncludeWithLeadingWhitespace_IsRecognized() throws IOException {
+ Map files = new HashMap<>();
+ files.put("extra.efx", "// included\n");
+
+ String input = " #include \"extra.efx\"\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ String result = processor.process(input);
+
+ assertEquals("// included\n\n", result);
+ }
+
+ @Test
+ void testIncludeBetweenContent_PreservesContext() throws IOException {
+ Map files = new HashMap<>();
+ files.put("middle.efx", "// middle\n");
+
+ String input = "// before\n#include \"middle.efx\"\n// after\n";
+ IncludeProcessor processor = new IncludeProcessor(mapResolver(files));
+ String result = processor.process(input);
+
+ assertEquals("// before\n// middle\n\n// after\n", result);
+ }
+
+ @Test
+ void testUnresolvablePath_ThrowsIOException() {
+ IncludedFileResolver resolver = path -> {
+ throw new IOException("Not found: " + path);
+ };
+
+ String input = "#include \"missing.efx\"\n";
+ IncludeProcessor processor = new IncludeProcessor(resolver);
+ assertThrows(IOException.class, () -> processor.process(input));
+ }
+}
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/complete-validation.sch b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/complete-validation.sch
new file mode 100644
index 0000000..361318b
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/complete-validation.sch
@@ -0,0 +1,28 @@
+
+
+
+ eForms schematron rules
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/validation-stage-1a-1.sch b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/validation-stage-1a-1.sch
new file mode 100644
index 0000000..180d143
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/validation-stage-1a-1.sch
@@ -0,0 +1,6 @@
+
+
+
+ rule|text|R-K7P-M2Q
+
+
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/validation-stage-1b-1.sch b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/validation-stage-1b-1.sch
new file mode 100644
index 0000000..04cbb22
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/dynamic/validation-stage-1b-1.sch
@@ -0,0 +1,6 @@
+
+
+
+ rule|text|R-X3F-N8W
+
+
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/extra-rules.efx b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/extra-rules.efx
new file mode 100644
index 0000000..b4f5a38
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/extra-rules.efx
@@ -0,0 +1,6 @@
+---- STAGE 1b ----
+
+WITH BT-00-Text
+ ASSERT BT-00-Text is present
+ AS ERROR R-X3F-N8W
+ FOR BT-00-Text IN 1
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/input.efx b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/input.efx
new file mode 100644
index 0000000..028cd6b
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/input.efx
@@ -0,0 +1,8 @@
+---- STAGE 1a ----
+
+WITH BT-00-Text
+ REPORT empty(BT-00-Text)
+ AS WARNING R-K7P-M2Q
+ FOR BT-00-Text IN 1
+
+#include "extra-rules.efx"
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/schematrons.json b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/schematrons.json
new file mode 100644
index 0000000..8de27c5
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/schematrons.json
@@ -0,0 +1,31 @@
+{
+ "schematrons" : [ {
+ "name" : "complete-validation",
+ "type" : "dynamic",
+ "filename" : "dynamic/complete-validation.sch"
+ }, {
+ "name" : "validation-stage-1a-1",
+ "type" : "dynamic",
+ "stage" : "1a",
+ "filename" : "dynamic/validation-stage-1a-1.sch"
+ }, {
+ "name" : "validation-stage-1b-1",
+ "type" : "dynamic",
+ "stage" : "1b",
+ "filename" : "dynamic/validation-stage-1b-1.sch"
+ }, {
+ "name" : "complete-validation",
+ "type" : "static",
+ "filename" : "static/complete-validation.sch"
+ }, {
+ "name" : "validation-stage-1a-1",
+ "type" : "static",
+ "stage" : "1a",
+ "filename" : "static/validation-stage-1a-1.sch"
+ }, {
+ "name" : "validation-stage-1b-1",
+ "type" : "static",
+ "stage" : "1b",
+ "filename" : "static/validation-stage-1b-1.sch"
+ } ]
+}
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/complete-validation.sch b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/complete-validation.sch
new file mode 100644
index 0000000..361318b
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/complete-validation.sch
@@ -0,0 +1,28 @@
+
+
+
+ eForms schematron rules
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/validation-stage-1a-1.sch b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/validation-stage-1a-1.sch
new file mode 100644
index 0000000..180d143
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/validation-stage-1a-1.sch
@@ -0,0 +1,6 @@
+
+
+
+ rule|text|R-K7P-M2Q
+
+
diff --git a/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/validation-stage-1b-1.sch b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/validation-stage-1b-1.sch
new file mode 100644
index 0000000..04cbb22
--- /dev/null
+++ b/src/test/resources/eu/europa/ted/efx/sdk2/EfxRulesTranslatorV2Test/testInclude_SingleFile_SameOutputAsInlined/static/validation-stage-1b-1.sch
@@ -0,0 +1,6 @@
+
+
+
+ rule|text|R-X3F-N8W
+
+