diff --git a/README.md b/README.md index 510500b..38ee342 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,14 @@ tasks.register('queryOne', name.jurgenei.gradle.xml.XQueryTask) { ```groovy tasks.register('validateSchematron', name.jurgenei.gradle.xml.SchematronTask) { schema 'src/main/schematron/rules.sch' + // Optional persistent compiled stylesheet cache. + style 'build/generated/schematron/rules.compiled.xsl' source(fileTree('src/main/xml') { include '**/*.xml' }) outputDir.set(layout.buildDirectory.dir('reports/schematron')) reportFormat.set(name.jurgenei.gradle.xml.validation.ReportFormat.SVRL_AND_JUNIT) + // Optional SchXslt transpiler parameters. + phase.set('#ALL') + severityThreshold.set('warning') workers.set(4) failOnError.set(false) } @@ -223,6 +228,18 @@ tasks.register('validateXsd', name.jurgenei.gradle.xml.XsdTask) { } ``` +Schematron-specific options: + +- `style(...)`/`style.set(...)` (optional): persistent location for compiled Schematron XSLT. + - When unset, a temp compiled stylesheet is used per validation run. + - When set, recompilation is skipped if the compiled stylesheet is newer than inputs and transpiler parameters are unchanged. +- `transpilerStylesheet(...)` (optional): override bundled SchXslt transpiler. +- Optional SchXslt transpiler parameter properties (only passed when explicitly set): + - `debug`, `phase`, `expandText`, `streamable`, `locationFunction`, `failEarly` + - `terminateValidationOnError`, `reportActivePattern`, `reportFiredRule`, `reportSuppressedRule` + - `reportSkippedAssertion`, `compactReport`, `severityThreshold`, `defaultSeverity`, `defaultFrom` + - `checkAssembledSchema`, `handleDynamicErrors` + ## Run tests ```bash diff --git a/build.gradle.kts b/build.gradle.kts index 43f09a3..8b47362 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "name.jurgenei.gradle" -version = "0.1.3" +version = "0.1.6" repositories { mavenCentral() diff --git a/samples/README.md b/samples/README.md index 6ca7e1a..6480ec0 100644 --- a/samples/README.md +++ b/samples/README.md @@ -16,7 +16,7 @@ pluginManagement { - `xslt-basic` - transform one XML with XSLT - `xquery-basic` - transform one XML with XQuery -- `validation-basic` - validate XML with XSD and Schematron (SVRL and JUnit reports) +- `validation-basic` - validate XML with XSD and Schematron (SVRL/JUnit), including a persisted compiled Schematron stylesheet (`style`) and transpiler params ## Run samples diff --git a/samples/validation-basic/build.gradle b/samples/validation-basic/build.gradle index e4a4ff3..daeeb10 100644 --- a/samples/validation-basic/build.gradle +++ b/samples/validation-basic/build.gradle @@ -14,9 +14,12 @@ tasks.register('runXsd', name.jurgenei.gradle.xml.XsdTask) { tasks.register('runSchematron', name.jurgenei.gradle.xml.SchematronTask) { schema 'src/main/schematron/rules.sch' + style 'build/generated/schematron/rules.compiled.xsl' source(fileTree('src/main/xml') { include '*.xml' }) outputDir.set(layout.buildDirectory.dir('reports/schematron')) reportFormat.set(name.jurgenei.gradle.xml.validation.ReportFormat.SVRL_AND_JUNIT) + phase.set('#ALL') + severityThreshold.set('warning') failOnError.set(false) } @@ -28,6 +31,7 @@ tasks.register('verifySample') { file('build/reports/xsd/invalid.svrl.xml'), file('build/reports/schematron/valid.svrl.xml'), file('build/reports/schematron/invalid.svrl.xml'), + file('build/generated/schematron/rules.compiled.xsl'), file('build/reports/xml-validation/junit/valid.junit.xml'), file('build/reports/xml-validation/junit/invalid.junit.xml') ] diff --git a/settings.gradle.kts b/settings.gradle.kts index 53baa46..8cc31a2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ -rootProject.name = "gradle-xml-transform" +rootProject.name = "gradle-xml-plugin" diff --git a/src/main/java/name/jurgenei/gradle/xml/AbstractXmlTransformTask.java b/src/main/java/name/jurgenei/gradle/xml/AbstractXmlTransformTask.java index 0108364..234f0e7 100644 --- a/src/main/java/name/jurgenei/gradle/xml/AbstractXmlTransformTask.java +++ b/src/main/java/name/jurgenei/gradle/xml/AbstractXmlTransformTask.java @@ -10,7 +10,9 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -42,6 +44,8 @@ @DisableCachingByDefault(because = "Uses external transform engines and file trees") public abstract class AbstractXmlTransformTask extends SourceTask { + private static final Set SUPPORTED_OUTPUT_METHODS = Set.of("xml", "json", "text"); + /** * Destination root directory for transformed files. * @@ -81,6 +85,18 @@ public abstract class AbstractXmlTransformTask extends SourceTask { @Input public abstract Property getOutputExtension(); + /** + * Optional explicit serializer output method used by Saxon. + * + *

Supported values are {@code xml}, {@code json}, and {@code text}. If not set, + * the method is inferred from the destination file extension.

+ * + * @return output method property + */ + @Input + @Optional + public abstract Property getOutputMethod(); + /** * Transform parameters exposed to the execution engine. * @@ -160,6 +176,15 @@ public void output(Object path) { getOutputFile().set(getProject().file(path)); } + /** + * Sets an explicit serializer output method (Gradle DSL friendly). + * + * @param method one of {@code xml}, {@code json}, {@code text} + */ + public void outputMethod(String method) { + getOutputMethod().set(method); + } + @Override @PathSensitive(PathSensitivity.RELATIVE) public org.gradle.api.file.FileTree getSource() { @@ -325,6 +350,42 @@ protected long latestDependencyTimestamp(File inputFile) { return inputFile.lastModified(); } + /** + * Resolves the serializer output method from explicit task setting or output extension. + * + * @param outputFile destination file + * @return serializer method to pass to Saxon + */ + protected String resolveSerializerMethod(File outputFile) { + if (getOutputMethod().isPresent()) { + return normalizeAndValidateMethod(getOutputMethod().get()); + } + return inferSerializerMethod(outputFile); + } + + private String inferSerializerMethod(File outputFile) { + String fileName = outputFile.getName().toLowerCase(Locale.ROOT); + if (fileName.endsWith(".json")) { + return "json"; + } + if (fileName.endsWith(".txt") || fileName.endsWith(".text")) { + return "text"; + } + return "xml"; + } + + private String normalizeAndValidateMethod(String configuredMethod) { + String normalized = configuredMethod == null ? "" : configuredMethod.trim().toLowerCase(Locale.ROOT); + if ("txt".equals(normalized)) { + normalized = "text"; + } + if (!SUPPORTED_OUTPUT_METHODS.contains(normalized)) { + throw new GradleException("Unsupported outputMethod '" + configuredMethod + + "'. Supported values: xml, json, text"); + } + return normalized; + } + private File fingerprintMarkerFor(File outputFile) { return new File(outputFile.getParentFile(), outputFile.getName() + ".inputs.sha256"); } @@ -349,6 +410,7 @@ private String computeNonFileInputFingerprint(Map params) { StringBuilder builder = new StringBuilder(); builder.append("task=").append(getClass().getName()).append('\n'); builder.append("outputExtension=").append(getOutputExtension().get()).append('\n'); + builder.append("outputMethod=").append(getOutputMethod().getOrElse("")).append('\n'); List keys = new ArrayList<>(params.keySet()); Collections.sort(keys); diff --git a/src/main/java/name/jurgenei/gradle/xml/AbstractXmlValidationTask.java b/src/main/java/name/jurgenei/gradle/xml/AbstractXmlValidationTask.java index 24e79b4..bfa8175 100644 --- a/src/main/java/name/jurgenei/gradle/xml/AbstractXmlValidationTask.java +++ b/src/main/java/name/jurgenei/gradle/xml/AbstractXmlValidationTask.java @@ -23,6 +23,7 @@ import org.gradle.api.provider.MapProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.PathSensitive; import org.gradle.api.tasks.PathSensitivity; @@ -63,6 +64,14 @@ public abstract class AbstractXmlValidationTask extends SourceTask implements Va @Input public abstract MapProperty getParams(); + /** + * Project directory used during execution-time path resolution. + * + * @return project directory property + */ + @Internal + public abstract DirectoryProperty getProjectDir(); + /** * Creates a validation task with default conventions. */ @@ -73,6 +82,7 @@ public AbstractXmlValidationTask() { getMaxFailures().convention(1); getReportFormat().convention(ReportFormat.SVRL); getJunitSuiteName().convention(getName()); + getProjectDir().convention(getProject().getLayout().getProjectDirectory()); getJunitOutputDir().convention(getProject().getLayout().getBuildDirectory().dir("reports/xml-validation/junit")); } @@ -198,7 +208,7 @@ private void validateOne(File inputFile, File outputRoot, Map rela private File svrlFileFor(File inputFile, File outputRoot, Map relativePaths) { Path inputPath = inputFile.toPath().toAbsolutePath().normalize(); - Path projectPath = getProject().getProjectDir().toPath().toAbsolutePath().normalize(); + Path projectPath = getProjectDir().get().getAsFile().toPath().toAbsolutePath().normalize(); String relative = relativePaths.get(inputPath); if (relative == null) { @@ -216,7 +226,7 @@ private File svrlFileFor(File inputFile, File outputRoot, Map rela private File junitFileFor(File inputFile, Map relativePaths) { Path inputPath = inputFile.toPath().toAbsolutePath().normalize(); - Path projectPath = getProject().getProjectDir().toPath().toAbsolutePath().normalize(); + Path projectPath = getProjectDir().get().getAsFile().toPath().toAbsolutePath().normalize(); String relative = relativePaths.get(inputPath); if (relative == null) { diff --git a/src/main/java/name/jurgenei/gradle/xml/SchematronTask.java b/src/main/java/name/jurgenei/gradle/xml/SchematronTask.java index 89af7e3..bb38a4b 100644 --- a/src/main/java/name/jurgenei/gradle/xml/SchematronTask.java +++ b/src/main/java/name/jurgenei/gradle/xml/SchematronTask.java @@ -5,18 +5,34 @@ import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import javax.xml.transform.Source; -import javax.xml.transform.TransformerFactory; +import javax.xml.transform.Transformer; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; +import net.sf.saxon.s9api.Processor; +import net.sf.saxon.s9api.QName; +import net.sf.saxon.s9api.Serializer; +import net.sf.saxon.s9api.XdmAtomicValue; +import net.sf.saxon.s9api.XsltCompiler; +import net.sf.saxon.s9api.XsltExecutable; +import net.sf.saxon.s9api.XsltTransformer; import name.jurgenei.gradle.xml.validation.SvrlSupport; import name.jurgenei.gradle.xml.validation.ValidationResult; import org.gradle.api.GradleException; import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; import org.gradle.api.tasks.PathSensitive; import org.gradle.api.tasks.PathSensitivity; @@ -28,6 +44,22 @@ @DisableCachingByDefault(because = "Schematron compilation and validation depends on external schema files") public abstract class SchematronTask extends AbstractXmlValidationTask { + private static final String SCHXSLT_NS = "http://dmaus.name/ns/2023/schxslt"; + private static final Set STATIC_TRANSPILER_PARAMS = Set.of( + "debug", + "streamable", + "location-function", + "fail-early", + "terminate-validation-on-error", + "report-active-pattern", + "report-fired-rule", + "report-suppressed-rule", + "report-skipped-assertion", + "compact-report", + "check-assembled-schema" + ); + private final Object transpilerCompileLock = new Object(); + /** * Creates a Schematron validation task. */ @@ -53,6 +85,211 @@ public SchematronTask() { @PathSensitive(PathSensitivity.RELATIVE) public abstract RegularFileProperty getTranspilerStylesheet(); + /** + * Optional destination for a compiled Schematron stylesheet. + * + *

When set, the task reuses this file and recompiles only when needed based on + * schema/transpiler timestamps and transpiler-parameter fingerprint changes.

+ * + * @return optional compiled stylesheet location + */ + @Optional + @OutputFile + public abstract RegularFileProperty getStyle(); + + /** + * SchXslt transpiler parameter {@code schxslt:debug}. + * Enable or disable debugging. When debugging is enable, the validation stylesheet is indented. + * Defaults to false. + * + * @return optional debug flag + */ + @Optional + @Input + public abstract Property getDebug(); + + /** + * SchXslt transpiler parameter {@code schxslt:phase}. + * Name of the validation phase. The value '#DEFAULT' selects the pattern in the + * {@code sch:schema/@defaultPhase} attribute or '#ALL' if this attribute is not present. + * The value '#ALL' selects all patterns. Defaults to '#DEFAULT'. + * + * @return optional validation phase + */ + @Optional + @Input + public abstract Property getPhase(); + + /** + * SchXslt transpiler parameter {@code schxslt:expand-text}. + * When set to boolean true, the validation stylesheet globally enables text value templates and + * you may use them in assertion or diagnostic messages. Defaults to false. + * + * @return optional expand-text flag + */ + @Optional + @Input + public abstract Property getExpandText(); + + /** + * SchXslt transpiler parameter {@code schxslt:streamable}. + * Set to boolean true to create a streamable validation stylesheet. This does not check the + * streamability of XPath expressions in rules, assertions, variables etc. It merely declares the + * modes in the validation stylesheet to be streamable and removes the {@code @location} attribute + * from the SVRL output when no location function is given because the default {@code fn:path()} + * is not streamable. Defaults to false. + * + * @return optional streamable flag + */ + @Optional + @Input + public abstract Property getStreamable(); + + /** + * SchXslt transpiler parameter {@code schxslt:location-function}. + * Name of a function {@code f($context as node()) as xs:string} that provides location information + * for the SVRL report. Defaults to {@code fn:path()} when not set. + * + * @return optional location function name + */ + @Optional + @Input + public abstract Property getLocationFunction(); + + /** + * SchXslt transpiler parameter {@code schxslt:fail-early}. + * When set to boolean true, the validation stylesheet stops as soon as it encounters the first + * failed assertion or successful report. Defaults to false. + * + * @return optional fail-early flag + */ + @Optional + @Input + public abstract Property getFailEarly(); + + /** + * SchXslt transpiler parameter {@code schxslt:terminate-validation-on-error}. + * When set to boolean true, the validation stylesheet terminates the XSLT processor when it + * encounters a dynamic error. Defaults to true. + * + * @return optional terminate-on-error flag + */ + @Optional + @Input + public abstract Property getTerminateValidationOnError(); + + /** + * SchXslt transpiler parameter {@code schxslt:report-active-pattern}. + * When set to boolean true, the validation stylesheet reports active patterns and groups. + * Defaults to true. + * + * @return optional report-active-pattern flag + */ + @Optional + @Input + public abstract Property getReportActivePattern(); + + /** + * SchXslt transpiler parameter {@code schxslt:report-fired-rule}. + * When set to boolean true, the validation stylesheet reports fired rules. Defaults to true. + * + * @return optional report-fired-rule flag + */ + @Optional + @Input + public abstract Property getReportFiredRule(); + + /** + * SchXslt transpiler parameter {@code schxslt:report-suppressed-rule}. + * When set to boolean true, the validation stylesheet reports suppressed rules. Defaults to true. + * + * @return optional report-suppressed-rule flag + */ + @Optional + @Input + public abstract Property getReportSuppressedRule(); + + /** + * SchXslt transpiler parameter {@code schxslt:report-skipped-assertion}. + * When set to boolean true, the validation stylesheet reports assertions that are skipped. + * Defaults to true. + * + * @return optional report-skipped-assertion flag + */ + @Optional + @Input + public abstract Property getReportSkippedAssertion(); + + /** + * SchXslt transpiler parameter {@code schxslt:compact-report}. + * When set to boolean true, the validation stylesheet only reports failed assertions, successful + * reports and errors. Defaults to false. + * + * @return optional compact-report flag + */ + @Optional + @Input + public abstract Property getCompactReport(); + + /** + * SchXslt transpiler parameter {@code schxslt:severity-threshold}. + * Assertions with a severity lesser than the threshold are not checked. One of 'info', 'warning', + * 'error', or 'fatal'. Defaults to 'info'. + * + * @return optional severity threshold + */ + @Optional + @Input + public abstract Property getSeverityThreshold(); + + /** + * SchXslt transpiler parameter {@code schxslt:default-severity}. + * Severity of assertions without an {@code @severity} attribute. One of 'info', 'warning', + * 'error', or 'fatal'. Defaults to 'fatal'. + * + * @return optional default severity + */ + @Optional + @Input + public abstract Property getDefaultSeverity(); + + /** + * SchXslt transpiler parameter {@code schxslt:default-from}. + * Default value of the expression that selects the subset of the document to be validate. + * Can be overwritten on a per-phase basis by the {@code @from} attribute. The default from + * expression also applies to the phase '#ALL'. Defaults to 'root()'. + * + * @return optional default from expression + */ + @Optional + @Input + public abstract Property getDefaultFrom(); + + /** + * SchXslt transpiler parameter {@code schxslt:check-assembled-schema}. + * When set to boolean true, the transpiler performs some plausability checks after all external + * definitions are included. It terminates with an error if it finds errors in the assembled + * schema. Defaults to false. + * + * @return optional check-assembled-schema flag + */ + @Optional + @Input + public abstract Property getCheckAssembledSchema(); + + /** + * SchXslt transpiler parameter {@code schxslt:handle-dynamic-errors}. + * When set to boolean true, the validation stylesheet writes dynamic errors as a + * {@code svrl:error} element to the validation report and terminates with a + * {@code schxslt:ValidationError} error. When set to false, dynamic errors bubble up to the + * XSLT processor. Defaults to true. + * + * @return optional handle-dynamic-errors flag + */ + @Optional + @Input + public abstract Property getHandleDynamicErrors(); + /** * Sets the Schematron schema file using Gradle file notation. * @@ -79,28 +316,259 @@ public void transpilerStylesheet(Object path) { getTranspilerStylesheet().set(file); } + /** + * Sets the compiled Schematron stylesheet output location. + * + * @param path file notation accepted by {@code Project.file} + */ + public void style(Object path) { + getStyle().set(getProject().file(path)); + } + @Override protected ValidationResult validate(File inputFile, Map params) throws Exception { - File compiledFile = Files.createTempFile("schematron-compiled-", ".xsl").toFile(); - compiledFile.deleteOnExit(); - - Source transpiler = loadTranspilerSource(); + Map transpilerParameters = collectTranspilerParameters(); + File compiledFile = resolveCompiledStylesheet(transpilerParameters); - TransformerFactory tf = TransformerFactory.newInstance(); - tf.newTransformer(transpiler).transform( - new StreamSource(getSchema().get().getAsFile()), - new StreamResult(compiledFile)); + Processor processor = new Processor(false); + XsltCompiler compiler = processor.newXsltCompiler(); + XsltExecutable executable = compiler.compile(new StreamSource(compiledFile)); + XsltTransformer validator = executable.load(); + validator.setSource(new StreamSource(inputFile)); StringWriter output = new StringWriter(); - tf.newTransformer(new StreamSource(compiledFile)).transform( - new StreamSource(inputFile), - new StreamResult(output)); + Serializer serializer = processor.newSerializer(output); + serializer.setOutputProperty(Serializer.Property.METHOD, "xml"); + if (getDebug().getOrElse(false)) { + // Keep debug mode readable for generated SVRL output. + serializer.setOutputProperty(Serializer.Property.INDENT, "yes"); + } + validator.setDestination(serializer); + validator.transform(); String svrlXml = output.toString(); List issues = SvrlSupport.parseSvrlIssues(svrlXml); return new ValidationResult(issues, svrlXml); } + + private File resolveCompiledStylesheet(Map transpilerParameters) throws Exception { + if (!getStyle().isPresent()) { + File compiledFile = Files.createTempFile("schematron-compiled-", ".xsl").toFile(); + compiledFile.deleteOnExit(); + compileSchematron(compiledFile, transpilerParameters); + return compiledFile; + } + + File compiledFile = getStyle().get().getAsFile(); + synchronized (transpilerCompileLock) { + if (needsRecompile(compiledFile, transpilerParameters)) { + compileSchematron(compiledFile, transpilerParameters); + writeFingerprint(fingerprintMarkerFor(compiledFile), computeTranspilerFingerprint(transpilerParameters)); + } + } + return compiledFile; + } + + private boolean needsRecompile(File compiledFile, Map transpilerParameters) { + if (!compiledFile.isFile()) { + return true; + } + + long compiledTimestamp = compiledFile.lastModified(); + long schemaTimestamp = getSchema().get().getAsFile().lastModified(); + if (compiledTimestamp < schemaTimestamp) { + return true; + } + + if (getTranspilerStylesheet().isPresent()) { + long transpilerTimestamp = getTranspilerStylesheet().get().getAsFile().lastModified(); + if (compiledTimestamp < transpilerTimestamp) { + return true; + } + } + + String currentFingerprint = computeTranspilerFingerprint(transpilerParameters); + return !isFingerprintCurrent(fingerprintMarkerFor(compiledFile), currentFingerprint); + } + + private void compileSchematron(File compiledFile, Map transpilerParameters) throws Exception { + File parent = compiledFile.getParentFile(); + if (parent != null) { + Files.createDirectories(parent.toPath()); + } + + Source transpiler = loadTranspilerSource(); + Processor processor = new Processor(false); + XsltCompiler compiler = processor.newXsltCompiler(); + + for (Map.Entry entry : transpilerParameters.entrySet()) { + String localName = localNameFromClarkName(entry.getKey()); + if (STATIC_TRANSPILER_PARAMS.contains(localName)) { + compiler.setParameter(qNameFromClarkName(entry.getKey()), toAtomicValue(entry.getValue())); + } + } + + XsltExecutable executable = compiler.compile(transpiler); + XsltTransformer transpilerTransformer = executable.load(); + + for (Map.Entry entry : transpilerParameters.entrySet()) { + String localName = localNameFromClarkName(entry.getKey()); + if (!STATIC_TRANSPILER_PARAMS.contains(localName)) { + transpilerTransformer.setParameter(qNameFromClarkName(entry.getKey()), toAtomicValue(entry.getValue())); + } + } + + transpilerTransformer.setSource(new StreamSource(getSchema().get().getAsFile())); + Serializer serializer = processor.newSerializer(compiledFile); + serializer.setOutputProperty(Serializer.Property.METHOD, "xml"); + transpilerTransformer.setDestination(serializer); + transpilerTransformer.transform(); + } + + private static QName qNameFromClarkName(String clarkName) { + int start = clarkName.indexOf('{'); + int end = clarkName.indexOf('}'); + if (start == 0 && end > 0 && end < clarkName.length() - 1) { + String namespace = clarkName.substring(1, end); + String localName = clarkName.substring(end + 1); + return new QName(namespace, localName); + } + return new QName(clarkName); + } + + private static String localNameFromClarkName(String clarkName) { + int end = clarkName.indexOf('}'); + if (clarkName.startsWith("{") && end > 0 && end < clarkName.length() - 1) { + return clarkName.substring(end + 1); + } + return clarkName; + } + + private static XdmAtomicValue toAtomicValue(Object value) { + if (value instanceof Boolean booleanValue) { + return new XdmAtomicValue(booleanValue); + } + return new XdmAtomicValue(value == null ? "" : value.toString()); + } + + private File fingerprintMarkerFor(File compiledFile) { + return new File(compiledFile.getParentFile(), compiledFile.getName() + ".transpiler.sha256"); + } + + private boolean isFingerprintCurrent(File markerFile, String currentFingerprint) { + if (!markerFile.isFile()) { + return false; + } + try { + String recorded = Files.readString(markerFile.toPath(), StandardCharsets.UTF_8).trim(); + return currentFingerprint.equals(recorded); + } catch (Exception ignored) { + return false; + } + } + + private void writeFingerprint(File markerFile, String fingerprint) throws Exception { + Files.writeString(markerFile.toPath(), fingerprint, StandardCharsets.UTF_8); + } + + private String computeTranspilerFingerprint(Map transpilerParameters) { + StringBuilder builder = new StringBuilder(); + builder.append("schema=").append(getSchema().get().getAsFile().toPath().toAbsolutePath().normalize()).append('\n'); + builder.append("schemaTimestamp=").append(getSchema().get().getAsFile().lastModified()).append('\n'); + + if (getTranspilerStylesheet().isPresent()) { + File transpilerFile = getTranspilerStylesheet().get().getAsFile(); + builder.append("transpiler=").append(transpilerFile.toPath().toAbsolutePath().normalize()).append('\n'); + builder.append("transpilerTimestamp=").append(transpilerFile.lastModified()).append('\n'); + } else { + builder.append("transpiler=classpath:content/transpile.xsl\n"); + } + + List names = new ArrayList<>(transpilerParameters.keySet()); + Collections.sort(names); + for (String name : names) { + Object value = transpilerParameters.get(name); + builder.append("transpilerParam:").append(name).append('=').append(value == null ? "" : value).append('\n'); + } + + return sha256(builder.toString()); + } + + private String sha256(String text) { + final byte[] digest; + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + digest = md.digest(text.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is unavailable", e); + } + + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } + + private Map collectTranspilerParameters() { + Map transpilerParams = new LinkedHashMap<>(); + + if (getDebug().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}debug", getDebug().get()); + } + if (getPhase().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}phase", getPhase().get()); + } + if (getExpandText().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}expand-text", getExpandText().get()); + } + if (getStreamable().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}streamable", getStreamable().get()); + } + if (getLocationFunction().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}location-function", getLocationFunction().get()); + } + if (getFailEarly().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}fail-early", getFailEarly().get()); + } + if (getTerminateValidationOnError().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}terminate-validation-on-error", getTerminateValidationOnError().get()); + } + if (getReportActivePattern().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}report-active-pattern", getReportActivePattern().get()); + } + if (getReportFiredRule().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}report-fired-rule", getReportFiredRule().get()); + } + if (getReportSuppressedRule().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}report-suppressed-rule", getReportSuppressedRule().get()); + } + if (getReportSkippedAssertion().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}report-skipped-assertion", getReportSkippedAssertion().get()); + } + if (getCompactReport().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}compact-report", getCompactReport().get()); + } + if (getSeverityThreshold().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}severity-threshold", getSeverityThreshold().get()); + } + if (getDefaultSeverity().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}default-severity", getDefaultSeverity().get()); + } + if (getDefaultFrom().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}default-from", getDefaultFrom().get()); + } + if (getCheckAssembledSchema().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}check-assembled-schema", getCheckAssembledSchema().get()); + } + if (getHandleDynamicErrors().isPresent()) { + transpilerParams.put("{" + SCHXSLT_NS + "}handle-dynamic-errors", getHandleDynamicErrors().get()); + } + + return transpilerParams; + } + private Source loadTranspilerSource() throws Exception { if (getTranspilerStylesheet().isPresent()) { return new StreamSource(getTranspilerStylesheet().get().getAsFile()); diff --git a/src/main/java/name/jurgenei/gradle/xml/XQueryTask.java b/src/main/java/name/jurgenei/gradle/xml/XQueryTask.java index 8654cb2..71818e1 100644 --- a/src/main/java/name/jurgenei/gradle/xml/XQueryTask.java +++ b/src/main/java/name/jurgenei/gradle/xml/XQueryTask.java @@ -5,7 +5,6 @@ import javax.xml.transform.stream.StreamSource; import net.sf.saxon.s9api.Processor; import net.sf.saxon.s9api.QName; -import net.sf.saxon.s9api.SaxonApiException; import net.sf.saxon.s9api.Serializer; import net.sf.saxon.s9api.XQueryCompiler; import net.sf.saxon.s9api.XQueryEvaluator; @@ -60,7 +59,7 @@ protected void transform(File inputFile, File outputFile, Map pa } Serializer serializer = processor.newSerializer(outputFile); - serializer.setOutputProperty(Serializer.Property.METHOD, "xml"); + serializer.setOutputProperty(Serializer.Property.METHOD, resolveSerializerMethod(outputFile)); evaluator.run(serializer); } diff --git a/src/main/java/name/jurgenei/gradle/xml/XsltTask.java b/src/main/java/name/jurgenei/gradle/xml/XsltTask.java index 9824d7c..ff7cd89 100644 --- a/src/main/java/name/jurgenei/gradle/xml/XsltTask.java +++ b/src/main/java/name/jurgenei/gradle/xml/XsltTask.java @@ -57,7 +57,7 @@ protected void transform(File inputFile, File outputFile, Map pa } Serializer serializer = processor.newSerializer(outputFile); - serializer.setOutputProperty(Serializer.Property.METHOD, "xml"); + serializer.setOutputProperty(Serializer.Property.METHOD, resolveSerializerMethod(outputFile)); transformer.setDestination(serializer); transformer.transform(); } diff --git a/src/test/java/name/jurgenei/gradle/xml/SchematronTaskIntegrationTest.java b/src/test/java/name/jurgenei/gradle/xml/SchematronTaskIntegrationTest.java index 102e0bd..b4ebeb7 100644 --- a/src/test/java/name/jurgenei/gradle/xml/SchematronTaskIntegrationTest.java +++ b/src/test/java/name/jurgenei/gradle/xml/SchematronTaskIntegrationTest.java @@ -1,12 +1,16 @@ package name.jurgenei.gradle.xml; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.BuildTask; import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -116,6 +120,182 @@ public void validatesFileTreeWithPatternsAndWorkers() throws IOException { assertTrue(read(outputB).contains("failed-assert")); } + /** + * Verifies compiled style reuse and recompilation triggers. + */ + @Test + public void reusesCompiledStyleAndRecompilesWhenTranspilerParametersChange() throws Exception { + write("settings.gradle", """ + rootProject.name = 'schematron-style-cache-test' + """); + write("build.gradle", """ + plugins { id 'name.jurgenei.gradle.xml' } + tasks.register('runSchematron', name.jurgenei.gradle.xml.SchematronTask) { + schema 'src/main/schematron/rules.sch' + transpilerStylesheet 'src/main/schematron/transpile.xsl' + style 'build/generated/schematron/compiled.xsl' + source 'src/main/xml/input.xml' + outputDir.set(layout.buildDirectory.dir('out/schematron')) + failOnError.set(false) + } + """); + + write("src/main/schematron/rules.sch", """ + + """); + write("src/main/schematron/transpile.xsl", transpiler()); + write("src/main/xml/input.xml", """ + BAD + """); + + GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runSchematron") + .build(); + + File compiledStyle = new File(testProjectDir.getRoot(), "build/generated/schematron/compiled.xsl"); + assertTrue(compiledStyle.exists()); + long firstCompiledTimestamp = compiledStyle.lastModified(); + + Thread.sleep(1200L); + + TaskOutcome secondOutcome = GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runSchematron") + .build() + .task(":runSchematron") + .getOutcome(); + + long secondCompiledTimestamp = compiledStyle.lastModified(); + assertTrue(secondOutcome == TaskOutcome.SUCCESS || secondOutcome == TaskOutcome.UP_TO_DATE); + assertTrue("Expected compiled style timestamp to stay unchanged when nothing changed", + secondCompiledTimestamp == firstCompiledTimestamp); + + write("build.gradle", """ + plugins { id 'name.jurgenei.gradle.xml' } + tasks.register('runSchematron', name.jurgenei.gradle.xml.SchematronTask) { + schema 'src/main/schematron/rules.sch' + transpilerStylesheet 'src/main/schematron/transpile.xsl' + style 'build/generated/schematron/compiled.xsl' + source 'src/main/xml/input.xml' + outputDir.set(layout.buildDirectory.dir('out/schematron')) + phase.set('#ALL') + failOnError.set(false) + } + """); + + Thread.sleep(1200L); + + GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runSchematron") + .build(); + + long thirdCompiledTimestamp = compiledStyle.lastModified(); + assertTrue("Expected compiled style timestamp to increase after transpiler parameter change", + thirdCompiledTimestamp > secondCompiledTimestamp); + } + + /** + * Verifies debug mode pretty-prints SVRL output for readability. + */ + @Test + public void debugProducesIndentedSvrlOutput() throws Exception { + write("settings.gradle", """ + rootProject.name = 'schematron-debug-format-test' + """); + write("build.gradle", """ + plugins { id 'name.jurgenei.gradle.xml' } + tasks.register('runSchematron', name.jurgenei.gradle.xml.SchematronTask) { + schema 'src/main/schematron/rules.sch' + style 'build/generated/schematron/compiled.xsl' + source 'src/main/xml/invalid.xml' + outputDir.set(layout.buildDirectory.dir('out/schematron')) + debug.set(true) + failOnError.set(false) + } + """); + + write("src/main/schematron/rules.sch", """ + + + + Value must be OK + + + + """); + write("src/main/xml/invalid.xml", """ + BAD + """); + + GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runSchematron") + .build(); + + File svrl = new File(testProjectDir.getRoot(), "build/out/schematron/invalid.svrl.xml"); + String svrlXml = read(svrl); + + assertTrue(svrl.exists()); + assertTrue(svrlXml.contains("failed-assert")); + assertTrue("Expected indented/debug-formatted SVRL with line breaks", svrlXml.contains("\n")); + assertTrue("Expected indented/debug-formatted SVRL elements", svrlXml.contains("\n + """); + write("src/main/schematron/transpile.xsl", transpiler()); + write("src/main/xml/invalid.xml", """ + BAD + """); + + BuildResult firstBuild = GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("schematronTask", "--configuration-cache", "--warning-mode=fail") + .build(); + + BuildResult secondBuild = GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("schematronTask", "--configuration-cache", "--warning-mode=fail") + .build(); + + BuildTask firstTask = firstBuild.task(":schematronTask"); + BuildTask secondTask = secondBuild.task(":schematronTask"); + assertNotNull(firstTask); + assertNotNull(secondTask); + + TaskOutcome firstOutcome = firstTask.getOutcome(); + TaskOutcome secondOutcome = secondTask.getOutcome(); + + assertTrue(firstOutcome == TaskOutcome.SUCCESS || firstOutcome == TaskOutcome.UP_TO_DATE); + assertTrue(secondOutcome == TaskOutcome.SUCCESS || secondOutcome == TaskOutcome.UP_TO_DATE); + } + private static String transpiler() { return """ Hello Gradle")); } + /** + * Verifies serializer method is inferred as json from output extension. + */ + @Test + public void infersJsonOutputMethodFromExtension() throws IOException { + write("settings.gradle", """ + rootProject.name = 'xquery-json-method-test' + """); + write("build.gradle", """ + plugins { id 'name.jurgenei.gradle.xml' } + tasks.register('runXQuery', name.jurgenei.gradle.xml.XQueryTask) { + query 'src/main/xquery/main.xq' + source 'src/main/xml/input.xml' + outputDir.set(layout.buildDirectory.dir('out/xquery')) + outputExtension.set('.json') + } + """); + + write("src/main/xml/input.xml", """ + Gradle + """); + write("src/main/xquery/main.xq", """ + map { 'value': data(/root/value) } + """); + + TaskOutcome outcome = GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runXQuery") + .build() + .task(":runXQuery") + .getOutcome(); + + assertEquals(TaskOutcome.SUCCESS, outcome); + + File output = new File(testProjectDir.getRoot(), "build/out/xquery/input.json"); + assertTrue(output.exists()); + assertEquals("{\"value\":\"Gradle\"}", read(output).replaceAll("\\s+", "")); + } + + /** + * Verifies explicit outputMethod overrides extension-based inference. + */ + @Test + public void usesExplicitTextOutputMethod() throws IOException { + write("settings.gradle", """ + rootProject.name = 'xquery-text-method-test' + """); + write("build.gradle", """ + plugins { id 'name.jurgenei.gradle.xml' } + tasks.register('runXQuery', name.jurgenei.gradle.xml.XQueryTask) { + query 'src/main/xquery/main.xq' + source 'src/main/xml/input.xml' + outputDir.set(layout.buildDirectory.dir('out/xquery')) + outputExtension.set('.xml') + outputMethod.set('text') + } + """); + + write("src/main/xml/input.xml", """ + Gradle + """); + write("src/main/xquery/main.xq", """ + concat('VALUE=', data(/root/value)) + """); + + TaskOutcome outcome = GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runXQuery") + .build() + .task(":runXQuery") + .getOutcome(); + + assertEquals(TaskOutcome.SUCCESS, outcome); + + File output = new File(testProjectDir.getRoot(), "build/out/xquery/input.xml"); + assertTrue(output.exists()); + assertEquals("VALUE=Gradle", read(output).trim()); + } + /** * Verifies explicit single-file mode using input/output properties. */ diff --git a/src/test/java/name/jurgenei/gradle/xml/XsltTaskIntegrationTest.java b/src/test/java/name/jurgenei/gradle/xml/XsltTaskIntegrationTest.java index d24bfaa..85fa40c 100644 --- a/src/test/java/name/jurgenei/gradle/xml/XsltTaskIntegrationTest.java +++ b/src/test/java/name/jurgenei/gradle/xml/XsltTaskIntegrationTest.java @@ -68,6 +68,97 @@ public void transformsSingleFileWithParameters() throws IOException { assertTrue(read(output).contains("Hello Gradle")); } + /** + * Verifies serializer method is inferred as json from output extension. + */ + @Test + public void infersJsonOutputMethodFromExtension() throws IOException { + write("settings.gradle", """ + rootProject.name = 'xslt-json-method-test' + """); + write("build.gradle", """ + plugins { id 'name.jurgenei.gradle.xml' } + tasks.register('runXslt', name.jurgenei.gradle.xml.XsltTask) { + style 'src/main/xslt/main.xsl' + source 'src/main/xml/input.xml' + outputDir.set(layout.buildDirectory.dir('out/xslt')) + outputExtension.set('.json') + } + """); + + write("src/main/xml/input.xml", """ + Gradle + """); + write("src/main/xslt/main.xsl", """ + + + + + + + """); + + TaskOutcome outcome = GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runXslt") + .build() + .task(":runXslt") + .getOutcome(); + + assertEquals(TaskOutcome.SUCCESS, outcome); + + File output = new File(testProjectDir.getRoot(), "build/out/xslt/input.json"); + assertTrue(output.exists()); + assertEquals("{\"value\":\"Gradle\"}", read(output).replaceAll("\\s+", "")); + } + + /** + * Verifies explicit outputMethod overrides extension-based inference. + */ + @Test + public void usesExplicitTextOutputMethod() throws IOException { + write("settings.gradle", """ + rootProject.name = 'xslt-text-method-test' + """); + write("build.gradle", """ + plugins { id 'name.jurgenei.gradle.xml' } + tasks.register('runXslt', name.jurgenei.gradle.xml.XsltTask) { + style 'src/main/xslt/main.xsl' + source 'src/main/xml/input.xml' + outputDir.set(layout.buildDirectory.dir('out/xslt')) + outputExtension.set('.xml') + outputMethod.set('text') + } + """); + + write("src/main/xml/input.xml", """ + Gradle + """); + write("src/main/xslt/main.xsl", """ + + + + + + + """); + + TaskOutcome outcome = GradleRunner.create() + .withProjectDir(testProjectDir.getRoot()) + .withPluginClasspath() + .withArguments("runXslt") + .build() + .task(":runXslt") + .getOutcome(); + + assertEquals(TaskOutcome.SUCCESS, outcome); + + File output = new File(testProjectDir.getRoot(), "build/out/xslt/input.xml"); + assertTrue(output.exists()); + assertEquals("VALUE=Gradle", read(output).trim()); + } + /** * Verifies explicit single-file mode using input/output properties. */