From 2fe565a0f276ac6d381223fdc038d270da5ec897 Mon Sep 17 00:00:00 2001 From: Vladimir Matveev Date: Thu, 14 May 2026 11:18:22 -0700 Subject: [PATCH] Added support for external readers in Gradle plugins (#1578) Adds support for configuring external module and resource readers in the Gradle plugin --- pkl-gradle/pkl-gradle.gradle.kts | 52 ++- .../org/pkl/gradle/test/extreader/Main.java | 101 +++++ .../main/java/org/pkl/gradle/PklPlugin.java | 55 +-- .../java/org/pkl/gradle/spec/BasePklSpec.java | 7 +- .../pkl/gradle/spec/ExternalReaderSpec.java | 58 +++ .../pkl/gradle/task/AnalyzeImportsTask.java | 2 + .../java/org/pkl/gradle/task/BasePklTask.java | 29 +- .../java/org/pkl/gradle/task/EvalTask.java | 2 + .../java/org/pkl/gradle/task/ModulesTask.java | 8 +- .../pkl/gradle/task/ProjectPackageTask.java | 2 + .../java/org/pkl/gradle/task/TestTask.java | 2 + .../org/pkl/gradle/utils/PluginUtils.java | 17 +- .../org/pkl/gradle/ExternalReadersTest.kt | 356 ++++++++++++++++++ 13 files changed, 644 insertions(+), 47 deletions(-) create mode 100644 pkl-gradle/src/externalReader/java/org/pkl/gradle/test/extreader/Main.java create mode 100644 pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java create mode 100644 pkl-gradle/src/test/kotlin/org/pkl/gradle/ExternalReadersTest.kt diff --git a/pkl-gradle/pkl-gradle.gradle.kts b/pkl-gradle/pkl-gradle.gradle.kts index c6b55f67..ae9b2925 100644 --- a/pkl-gradle/pkl-gradle.gradle.kts +++ b/pkl-gradle/pkl-gradle.gradle.kts @@ -63,13 +63,59 @@ sourceSets { } } +// Support for testing with a real external reader in tests - this builds an additional source set +// into a jar with a main class which provides a simple external reader implementation. +// Then the path to the jar file and the toolchain's `java` binary +// are injected into tests as properties. + +val externalReader by sourceSets.creating {} + +dependencies { "externalReaderImplementation"(libs.msgpack) } + +val externalReaderJar by + tasks.registering(Jar::class) { + description = "Builds an external reader executable jar file" + archiveBaseName = "external-reader" + archiveVersion = "" + + // Package all dependencies into the jar (shadow plugin lite). + from( + externalReader.runtimeClasspath.elements.map { locations -> + locations.mapNotNull { location -> + val f = location.asFile + when { + f.isDirectory -> f + f.isFile -> zipTree(f) + else -> null + } + } + } + ) + + manifest { attributes("Main-Class" to "org.pkl.gradle.test.extreader.Main") } + } + +tasks.test { + dependsOn(externalReaderJar) + // Currently the only way to inject system properties from lazy values in Gradle + // is via `jvmArgumentProviders`. + jvmArgumentProviders += CommandLineArgumentProvider { + listOf( + "-DpklGradle.externalReaderJar=" + + externalReaderJar.get().archiveFile.get().asFile.absolutePath, + "-DpklGradle.javaExecutable=" + + javaToolchains.launcherFor(java.toolchain).get().executablePath.asFile.absolutePath, + ) + } +} + publishing { publications { withType().configureEach { pom { - name.set("pkl-gradle plugin") - url.set("https://github.com/apple/pkl/tree/main/pkl-gradle") - description.set("Gradle plugin for the Pkl configuration language.") + name = "pkl-gradle plugin" + url = "https://github.com/apple/pkl/tree/main/pkl-gradle" + description = "Gradle plugin for the Pkl configuration language." } } } diff --git a/pkl-gradle/src/externalReader/java/org/pkl/gradle/test/extreader/Main.java b/pkl-gradle/src/externalReader/java/org/pkl/gradle/test/extreader/Main.java new file mode 100644 index 00000000..aba2a5ea --- /dev/null +++ b/pkl-gradle/src/externalReader/java/org/pkl/gradle/test/extreader/Main.java @@ -0,0 +1,101 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.gradle.test.extreader; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.msgpack.core.MessagePack; +import org.msgpack.value.Value; +import org.msgpack.value.ValueFactory; + +/** + * A minimal external resource reader for Pkl. Uppercases the scheme-specific part of the URI and + * returns it as binary content. Implements the Pkl external reader MessagePack protocol over + * stdin/stdout. + */ +public class Main { + private static final int INITIALIZE_RESOURCE_READER_REQUEST = 0x30; + private static final int INITIALIZE_RESOURCE_READER_RESPONSE = 0x31; + private static final int READ_RESOURCE_REQUEST = 0x26; + private static final int READ_RESOURCE_RESPONSE = 0x27; + private static final int CLOSE_EXTERNAL_PROCESS = 0x32; + + private static final Value KEY_REQUEST_ID = ValueFactory.newString("requestId"); + private static final Value KEY_EVALUATOR_ID = ValueFactory.newString("evaluatorId"); + private static final Value KEY_SCHEME = ValueFactory.newString("scheme"); + private static final Value KEY_URI = ValueFactory.newString("uri"); + + public static void main(String[] args) throws IOException { + var unpacker = MessagePack.newDefaultUnpacker(System.in); + var packer = MessagePack.newDefaultPacker(System.out); + + while (unpacker.hasNext()) { + var arrayLen = unpacker.unpackArrayHeader(); + if (arrayLen != 2) { + throw new IOException("Expected array of 2, got " + arrayLen); + } + var msgType = unpacker.unpackInt(); + var body = unpacker.unpackValue().asMapValue().map(); + + switch (msgType) { + case INITIALIZE_RESOURCE_READER_REQUEST -> { + var requestId = body.get(KEY_REQUEST_ID).asIntegerValue().asLong(); + var scheme = body.get(KEY_SCHEME).asStringValue().asString(); + + packer.packArrayHeader(2); + packer.packInt(INITIALIZE_RESOURCE_READER_RESPONSE); + packer.packMapHeader(2); + packer.packString("requestId"); + packer.packLong(requestId); + packer.packString("spec"); + packer.packMapHeader(3); + packer.packString("scheme"); + packer.packString(scheme); + packer.packString("hasHierarchicalUris"); + packer.packBoolean(false); + packer.packString("isGlobbable"); + packer.packBoolean(false); + packer.flush(); + } + case READ_RESOURCE_REQUEST -> { + var requestId = body.get(KEY_REQUEST_ID).asIntegerValue().asLong(); + var evaluatorId = body.get(KEY_EVALUATOR_ID).asIntegerValue().asLong(); + var uri = body.get(KEY_URI).asStringValue().asString(); + + var colonIndex = uri.indexOf(':'); + var schemeSpecific = colonIndex >= 0 ? uri.substring(colonIndex + 1) : uri; + var contents = schemeSpecific.toUpperCase().getBytes(StandardCharsets.UTF_8); + + packer.packArrayHeader(2); + packer.packInt(READ_RESOURCE_RESPONSE); + packer.packMapHeader(3); + packer.packString("requestId"); + packer.packLong(requestId); + packer.packString("evaluatorId"); + packer.packLong(evaluatorId); + packer.packString("contents"); + packer.packBinaryHeader(contents.length); + packer.writePayload(contents); + packer.flush(); + } + case CLOSE_EXTERNAL_PROCESS -> { + return; + } + default -> {} + } + } + } +} diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java index 28805905..1e28b28d 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -26,8 +26,10 @@ import org.gradle.api.NamedDomainObjectContainer; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.Transformer; +import org.gradle.api.file.ProjectLayout; import org.gradle.api.file.SourceDirectorySet; import org.gradle.api.provider.Provider; +import org.gradle.api.provider.ProviderFactory; import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSetContainer; import org.gradle.api.tasks.TaskProvider; @@ -138,11 +140,13 @@ public class PklPlugin implements Plugin { configureBaseSpec(project, spec); spec.getOutputFormat().convention(OutputFormat.PCF.toString()); var analyzeImportsTask = createTask(project, AnalyzeImportsTask.class, spec); + var layout = project.getLayout(); + var providers = project.getProviders(); analyzeImportsTask.configure( task -> { task.getOutputFormat().set(spec.getOutputFormat()); task.getOutputFile().set(spec.getOutputFile()); - configureModulesTask(project, task, spec, null); + configureModulesTask(layout, providers, task, spec, null, null); }); }); } @@ -465,8 +469,8 @@ public class PklPlugin implements Plugin { } private void configureBaseTask( - Project project, T task, S spec) { - task.getWorkingDir().set(project.getLayout().getProjectDirectory()); + ProjectLayout layout, ProviderFactory providers, T task, S spec) { + task.getWorkingDir().set(layout.getProjectDirectory()); task.getAllowedModules().set(spec.getAllowedModules()); task.getAllowedResources().set(spec.getAllowedResources()); task.getEnvironmentVariables().set(spec.getEnvironmentVariables()); @@ -482,15 +486,20 @@ public class PklPlugin implements Plugin { task.getHttpProxy().set(spec.getHttpProxy()); task.getHttpNoProxy().set(spec.getHttpNoProxy()); task.getHttpRewrites().set(spec.getHttpRewrites()); + task.getExternalModuleReaders() + .set(providers.provider(() -> spec.getExternalModuleReaders().getAsMap())); + task.getExternalResourceReaders() + .set(providers.provider(() -> spec.getExternalResourceReaders().getAsMap())); } private void configureModulesTask( - Project project, + ProjectLayout layout, + ProviderFactory providers, T task, S spec, @Nullable TaskProvider analyzeImportsTask, @Nullable Transformer, List> mapSourceModules) { - configureBaseTask(project, task, spec); + configureBaseTask(layout, providers, task, spec); if (mapSourceModules != null) { task.getSourceModules().set(spec.getSourceModules().map(mapSourceModules)); } else { @@ -513,21 +522,12 @@ public class PklPlugin implements Plugin { } } - private void configureModulesTask( - Project project, - T task, - S spec, - @Nullable TaskProvider analyzeImportsTask) { - configureModulesTask(project, task, spec, analyzeImportsTask, null); - } - - private TaskProvider createAnalyzeImportsTask( + private TaskProvider createGatherImportsTask( Project project, ModulesSpec spec) { + var layout = project.getLayout(); var outputFile = - project - .getLayout() - .getBuildDirectory() - .file("pkl-gradle/imports/" + spec.getName() + ".json"); + layout.getBuildDirectory().file("pkl-gradle/imports/" + spec.getName() + ".json"); + var providers = project.getProviders(); return project .getTasks() .register( @@ -535,7 +535,8 @@ public class PklPlugin implements Plugin { AnalyzeImportsTask.class, task -> { configureModulesTask( - project, + layout, + providers, task, spec, null, @@ -550,7 +551,10 @@ public class PklPlugin implements Plugin { (it) -> it.getScheme() == null || it.getScheme().equalsIgnoreCase("file")) .toList()); - task.setDescription("Compute the set of imports declared by input modules"); + task.setDescription( + "Compute the set of imports declared by input modules of " + + spec.getName() + + " Pkl operation"); task.setGroup("build"); task.getOutputFormat().set(OutputFormat.JSON.toString()); task.getOutputFile().set(outputFile); @@ -570,20 +574,25 @@ public class PklPlugin implements Plugin { */ private TaskProvider createModulesTask( Project project, Class taskClass, ModulesSpec spec) { - var analyzeImportsTask = createAnalyzeImportsTask(project, spec); + var gatherImportsTask = createGatherImportsTask(project, spec); + var layout = project.getLayout(); + var providers = project.getProviders(); return project .getTasks() .register( spec.getName(), taskClass, - task -> configureModulesTask(project, task, spec, analyzeImportsTask)); + task -> configureModulesTask(layout, providers, task, spec, gatherImportsTask, null)); } private TaskProvider createTask( Project project, Class taskClass, BasePklSpec spec) { + var layout = project.getLayout(); + var providers = project.getProviders(); return project .getTasks() - .register(spec.getName(), taskClass, task -> configureBaseTask(project, task, spec)); + .register( + spec.getName(), taskClass, task -> configureBaseTask(layout, providers, task, spec)); } private Set append(Set set1, T element) { diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java index 09a1edca..32ca81b5 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/BasePklSpec.java @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package org.pkl.gradle.spec; import java.net.URI; import java.time.Duration; +import org.gradle.api.NamedDomainObjectContainer; import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.provider.ListProperty; @@ -59,4 +60,8 @@ public interface BasePklSpec { ListProperty getHttpNoProxy(); MapProperty getHttpRewrites(); + + NamedDomainObjectContainer getExternalModuleReaders(); + + NamedDomainObjectContainer getExternalResourceReaders(); } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java new file mode 100644 index 00000000..0d856fb8 --- /dev/null +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java @@ -0,0 +1,58 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.gradle.spec; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; +import javax.inject.Inject; +import org.gradle.api.Named; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader; + +public abstract class ExternalReaderSpec implements Named { + private final String name; + + @Inject + public ExternalReaderSpec(String name) { + this.name = name; + } + + @Override + @Input + public String getName() { + return name; + } + + @Input + public abstract Property getExecutable(); + + @Input + public abstract ListProperty getArguments(); + + public ExternalReader toExternalReader() { + return new ExternalReader(getExecutable().get(), getArguments().get()); + } + + public static Map toExternalReaderMap( + Collection externalReaderSpecs) { + return externalReaderSpecs.stream() + .collect( + Collectors.toMap(ExternalReaderSpec::getName, ExternalReaderSpec::toExternalReader)); + } +} diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/AnalyzeImportsTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/AnalyzeImportsTask.java index 5c3d93b0..6caa2969 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/AnalyzeImportsTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/AnalyzeImportsTask.java @@ -15,6 +15,8 @@ */ package org.pkl.gradle.task; +import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull; + import java.io.File; import org.gradle.api.file.RegularFileProperty; import org.gradle.api.provider.Property; diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java index f628ec2a..44dac3d3 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/BasePklTask.java @@ -15,6 +15,9 @@ */ package org.pkl.gradle.task; +import static org.pkl.gradle.spec.ExternalReaderSpec.toExternalReaderMap; +import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull; + import java.io.File; import java.net.URI; import java.nio.file.Path; @@ -22,8 +25,6 @@ import java.nio.file.Paths; import java.time.Duration; import java.util.Collections; import java.util.List; -import java.util.Map; -import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.inject.Inject; @@ -42,6 +43,7 @@ import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFile; import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Nested; import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.PathSensitive; import org.gradle.api.tasks.PathSensitivity; @@ -50,6 +52,7 @@ import org.jspecify.annotations.Nullable; import org.pkl.commons.cli.CliBaseOptions; import org.pkl.core.Pair; import org.pkl.core.evaluatorSettings.Color; +import org.pkl.gradle.spec.ExternalReaderSpec; import org.pkl.gradle.utils.PluginUtils; @CacheableTask @@ -170,6 +173,12 @@ public abstract class BasePklTask extends DefaultTask { @Optional public abstract Property getPowerAssertions(); + @Nested + public abstract MapProperty getExternalModuleReaders(); + + @Nested + public abstract MapProperty getExternalResourceReaders(); + /** * There are issues with using native libraries in Gradle plugins. As a workaround for now, make * Truffle use an un-optimized runtime. @@ -224,8 +233,8 @@ public abstract class BasePklTask extends DefaultTask { getHttpNoProxy().getOrElse(List.of()), getHttpRewrites().getOrNull(), getHttpHeaders().getOrNull(), - Map.of(), - Map.of(), + toExternalReaderMap(getExternalModuleReaders().get().values()), + toExternalReaderMap(getExternalResourceReaders().get().values()), null, getPowerAssertions().getOrElse(false)); } @@ -248,16 +257,4 @@ public abstract class BasePklTask extends DefaultTask { protected List patternsFromStrings(List patterns) { return patterns.stream().map(Pattern::compile).collect(Collectors.toList()); } - - /** - * Equivalent to {@code provider.map(it -> f.apply(it)).getOrNull()}. - * - *

This function is necessary because in some cases doing {@code - * someProvider.map(...).getOrNull()} may trigger validation errors inside Gradle, when {@code - * someProvider} is derived from a property. - */ - protected @Nullable U mapAndGetOrNull(Provider provider, Function f) { - @Nullable T value = provider.getOrNull(); - return value == null ? null : f.apply(value); - } } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/EvalTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/EvalTask.java index 804f38e4..bfcc9d53 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/EvalTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/EvalTask.java @@ -15,6 +15,8 @@ */ package org.pkl.gradle.task; +import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull; + import java.io.File; import java.util.Collections; import java.util.Set; diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java index d32c23c8..38d64180 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/ModulesTask.java @@ -15,13 +15,15 @@ */ package org.pkl.gradle.task; +import static org.pkl.gradle.spec.ExternalReaderSpec.toExternalReaderMap; +import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull; + import java.io.File; import java.net.URI; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; import org.gradle.api.InvalidUserDataException; import org.gradle.api.file.DirectoryProperty; @@ -165,8 +167,8 @@ public abstract class ModulesTask extends BasePklTask { List.of(), getHttpRewrites().getOrNull(), getHttpHeaders().getOrNull(), - Map.of(), - Map.of(), + toExternalReaderMap(getExternalModuleReaders().get().values()), + toExternalReaderMap(getExternalResourceReaders().get().values()), null, getPowerAssertions().getOrElse(false)); } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java index 0281c143..b1ff4358 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java @@ -15,6 +15,8 @@ */ package org.pkl.gradle.task; +import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull; + import java.io.PrintWriter; import java.nio.file.Path; import java.util.stream.Collectors; diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/TestTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/TestTask.java index 409692e6..ac6a82c3 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/TestTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/TestTask.java @@ -15,6 +15,8 @@ */ package org.pkl.gradle.task; +import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull; + import java.io.PrintWriter; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.provider.Property; diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/utils/PluginUtils.java b/pkl-gradle/src/main/java/org/pkl/gradle/utils/PluginUtils.java index 43e5e103..0c9eac65 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/utils/PluginUtils.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/utils/PluginUtils.java @@ -25,13 +25,16 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collections; import java.util.List; +import java.util.function.Function; import org.gradle.api.InvalidUserDataException; import org.gradle.api.file.FileSystemLocation; import org.gradle.api.file.RegularFile; +import org.gradle.api.provider.Provider; +import org.jspecify.annotations.Nullable; import org.pkl.core.ImportGraph; import org.pkl.core.util.IoUtils; -public class PluginUtils { +public final class PluginUtils { private PluginUtils() {} /** @@ -160,4 +163,16 @@ public class PluginUtils { "Failed to parse transitive imports from " + outputFile.getAsFile(), e); } } + + /** + * Equivalent to {@code provider.map(it -> f.apply(it)).getOrNull()}. + * + *

This function is necessary because in some cases doing {@code + * someProvider.map(...).getOrNull()} may trigger validation errors inside Gradle, when {@code + * someProvider} is derived from a property. + */ + public static @Nullable U mapAndGetOrNull(Provider provider, Function f) { + @Nullable T value = provider.getOrNull(); + return value == null ? null : f.apply(value); + } } diff --git a/pkl-gradle/src/test/kotlin/org/pkl/gradle/ExternalReadersTest.kt b/pkl-gradle/src/test/kotlin/org/pkl/gradle/ExternalReadersTest.kt new file mode 100644 index 00000000..599973bb --- /dev/null +++ b/pkl-gradle/src/test/kotlin/org/pkl/gradle/ExternalReadersTest.kt @@ -0,0 +1,356 @@ +/* + * Copyright © 2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.gradle + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIf + +class ExternalReadersTest : AbstractTest() { + companion object { + // Adjust paths on Windows to prevent unexpected character escapes. + private fun getPathSafeSystemProperty(name: String): String? = + System.getProperty(name)?.replace('\\', '/') + + private val externalReaderJar: String? by lazy { + getPathSafeSystemProperty("pklGradle.externalReaderJar") + } + + private val javaExecutable: String? by lazy { + getPathSafeSystemProperty("pklGradle.javaExecutable") + } + + @JvmStatic + fun systemPropertiesAreNotSet(): Boolean { + return externalReaderJar == null || javaExecutable == null + } + } + + @Test + fun `external module readers DSL is accepted`() { + writeBuildFile( + externalModuleReaders = + """ + externalModuleReaders { + myscheme { + executable = "/nonexistent/my-reader" + arguments = ["--arg1", "--arg2"] + } + } + """ + .trimIndent() + ) + writePklFile() + runTask("evalTest") + } + + @Test + fun `external resource readers DSL is accepted`() { + writeBuildFile( + externalResourceReaders = + """ + externalResourceReaders { + myscheme { + executable = "/nonexistent/my-resource-reader" + arguments = ["--resource"] + } + } + """ + .trimIndent() + ) + writePklFile() + runTask("evalTest") + } + + @Test + fun `multiple external readers can be configured`() { + writeBuildFile( + externalModuleReaders = + """ + externalModuleReaders { + scheme1 { + executable = "/nonexistent/reader1" + arguments = ["--mod"] + } + scheme2 { + executable = "/nonexistent/reader2" + arguments = [] + } + } + """ + .trimIndent(), + externalResourceReaders = + """ + externalResourceReaders { + scheme3 { + executable = "/nonexistent/reader3" + arguments = ["--res", "--verbose"] + } + scheme4 { + executable = "/nonexistent/reader4" + arguments = [] + } + } + """ + .trimIndent(), + ) + writePklFile() + runTask("evalTest") + } + + @Test + fun `external module reader with invalid executable produces error`() { + writeBuildFile( + externalModuleReaders = + """ + externalModuleReaders { + myscheme { + executable = "/nonexistent/my-reader" + arguments = [] + } + } + """ + .trimIndent(), + additionalContents = + """ + allowedModules = ["repl:", "file:", "modulepath:", "https:", "pkl:", "package:", "projectpackage:", "myscheme:"] + """ + .trimIndent(), + ) + writePklFile( + """ + import "myscheme:/something" + result = 1 + """ + .trimIndent() + ) + val result = runTask("evalTest", expectFailure = true) + assertThat(result.output).contains("/nonexistent/my-reader") + } + + @Test + fun `external resource reader with invalid executable produces error`() { + writeBuildFile( + externalResourceReaders = + """ + externalResourceReaders { + myscheme { + executable = "/nonexistent/my-resource-reader" + arguments = [] + } + } + """ + .trimIndent(), + additionalContents = + """ + allowedResources = ["env:", "prop:", "file:", "modulepath:", "https:", "package:", "myscheme:"] + """ + .trimIndent(), + ) + writePklFile( + """ + result = read("myscheme:/something") + """ + .trimIndent() + ) + val result = runTask("evalTest", expectFailure = true) + assertThat(result.output).contains("/nonexistent/my-resource-reader") + } + + @Test + fun `external module reader scheme must be in allowedModules`() { + writeBuildFile( + externalModuleReaders = + """ + externalModuleReaders { + myscheme { + executable = "/nonexistent/my-reader" + arguments = [] + } + } + """ + .trimIndent() + ) + writePklFile( + """ + import "myscheme:/something" + result = 1 + """ + .trimIndent() + ) + val result = runTask("evalTest", expectFailure = true) + assertThat(result.output).containsAnyOf("myscheme:/something", "/nonexistent/my-reader") + } + + @Test + fun `external resource reader scheme must be in allowedResources`() { + writeBuildFile( + externalResourceReaders = + """ + externalResourceReaders { + myscheme { + executable = "/nonexistent/my-resource-reader" + arguments = [] + } + } + """ + .trimIndent() + ) + writePklFile( + """ + result = read("myscheme:/something") + """ + .trimIndent() + ) + val result = runTask("evalTest", expectFailure = true) + assertThat(result.output).contains("myscheme:/something") + } + + @Test + @DisabledIf("systemPropertiesAreNotSet") + fun `external resource reader reads and uppercases content`() { + writeBuildFile( + externalResourceReaders = + """ + externalResourceReaders { + upper { + executable = "$javaExecutable" + arguments = ["-jar", "$externalReaderJar"] + } + } + """ + .trimIndent(), + additionalContents = + """ + allowedResources = ["env:", "prop:", "file:", "modulepath:", "https:", "package:", "upper:"] + """ + .trimIndent(), + ) + writePklFile( + """ + result = read("upper:hello-world").text + """ + .trimIndent() + ) + runTask("evalTest") + val outputFile = testProjectDir.resolve("test.pcf") + checkFileContents(outputFile, """result = "HELLO-WORLD"""") + } + + @Test + @DisabledIf("systemPropertiesAreNotSet") + fun `external resource reader handles path-like URI`() { + writeBuildFile( + externalResourceReaders = + """ + externalResourceReaders { + upper { + executable = "$javaExecutable" + arguments = ["-jar", "$externalReaderJar"] + } + } + """ + .trimIndent(), + additionalContents = + """ + allowedResources = ["env:", "prop:", "file:", "modulepath:", "https:", "package:", "upper:"] + """ + .trimIndent(), + ) + writePklFile( + """ + result = read("upper:/some/path").text + """ + .trimIndent() + ) + runTask("evalTest") + val outputFile = testProjectDir.resolve("test.pcf") + checkFileContents(outputFile, """result = "/SOME/PATH"""") + } + + @Test + fun `external readers are configuration cache compatible`() { + writeBuildFile( + externalModuleReaders = + """ + externalModuleReaders { + myscheme { + executable = "/nonexistent/my-reader" + arguments = ["--arg1"] + } + } + """ + .trimIndent(), + externalResourceReaders = + """ + externalResourceReaders { + myresscheme { + executable = "/nonexistent/my-resource-reader" + arguments = ["--res"] + } + } + """ + .trimIndent(), + ) + writePklFile() + + val (firstRun, secondRun) = runTaskWithConfigurationCache("evalTest") + + assertThat(firstRun.output).contains(CONFIG_CACHE_STORED) + assertThat(secondRun.output).contains(CONFIG_CACHE_REUSED) + } + + private fun writeBuildFile( + outputFormat: String = "pcf", + externalModuleReaders: String = "", + externalResourceReaders: String = "", + additionalContents: String = "", + ) { + writeFile( + "build.gradle", + """ + plugins { + id "org.pkl-lang" + } + + pkl { + evaluators { + evalTest { + sourceModules = ["test.pkl"] + outputFormat = "$outputFormat" + settingsModule = "pkl:settings" + $additionalContents + $externalModuleReaders + $externalResourceReaders + } + } + } + """, + ) + } + + private fun writePklFile( + contents: String = + """ + person { + name = "Pigeon" + age = 20 + 10 + } + """ + ) { + writeFile("test.pkl", contents) + } +}