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 + +