Added support for external readers in Gradle plugins (#1578)

Adds support for configuring external module and resource readers in the Gradle plugin
This commit is contained in:
Vladimir Matveev
2026-05-14 11:18:22 -07:00
committed by GitHub
parent 1b6e89c971
commit 2fe565a0f2
13 changed files with 644 additions and 47 deletions
+49 -3
View File
@@ -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 { publishing {
publications { publications {
withType<MavenPublication>().configureEach { withType<MavenPublication>().configureEach {
pom { pom {
name.set("pkl-gradle plugin") name = "pkl-gradle plugin"
url.set("https://github.com/apple/pkl/tree/main/pkl-gradle") url = "https://github.com/apple/pkl/tree/main/pkl-gradle"
description.set("Gradle plugin for the Pkl configuration language.") description = "Gradle plugin for the Pkl configuration language."
} }
} }
} }
@@ -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 -> {}
}
}
}
}
@@ -26,8 +26,10 @@ import org.gradle.api.NamedDomainObjectContainer;
import org.gradle.api.Plugin; import org.gradle.api.Plugin;
import org.gradle.api.Project; import org.gradle.api.Project;
import org.gradle.api.Transformer; import org.gradle.api.Transformer;
import org.gradle.api.file.ProjectLayout;
import org.gradle.api.file.SourceDirectorySet; import org.gradle.api.file.SourceDirectorySet;
import org.gradle.api.provider.Provider; import org.gradle.api.provider.Provider;
import org.gradle.api.provider.ProviderFactory;
import org.gradle.api.tasks.SourceSet; import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.SourceSetContainer; import org.gradle.api.tasks.SourceSetContainer;
import org.gradle.api.tasks.TaskProvider; import org.gradle.api.tasks.TaskProvider;
@@ -138,11 +140,13 @@ public class PklPlugin implements Plugin<Project> {
configureBaseSpec(project, spec); configureBaseSpec(project, spec);
spec.getOutputFormat().convention(OutputFormat.PCF.toString()); spec.getOutputFormat().convention(OutputFormat.PCF.toString());
var analyzeImportsTask = createTask(project, AnalyzeImportsTask.class, spec); var analyzeImportsTask = createTask(project, AnalyzeImportsTask.class, spec);
var layout = project.getLayout();
var providers = project.getProviders();
analyzeImportsTask.configure( analyzeImportsTask.configure(
task -> { task -> {
task.getOutputFormat().set(spec.getOutputFormat()); task.getOutputFormat().set(spec.getOutputFormat());
task.getOutputFile().set(spec.getOutputFile()); 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<Project> {
} }
private <T extends BasePklTask, S extends BasePklSpec> void configureBaseTask( private <T extends BasePklTask, S extends BasePklSpec> void configureBaseTask(
Project project, T task, S spec) { ProjectLayout layout, ProviderFactory providers, T task, S spec) {
task.getWorkingDir().set(project.getLayout().getProjectDirectory()); task.getWorkingDir().set(layout.getProjectDirectory());
task.getAllowedModules().set(spec.getAllowedModules()); task.getAllowedModules().set(spec.getAllowedModules());
task.getAllowedResources().set(spec.getAllowedResources()); task.getAllowedResources().set(spec.getAllowedResources());
task.getEnvironmentVariables().set(spec.getEnvironmentVariables()); task.getEnvironmentVariables().set(spec.getEnvironmentVariables());
@@ -482,15 +486,20 @@ public class PklPlugin implements Plugin<Project> {
task.getHttpProxy().set(spec.getHttpProxy()); task.getHttpProxy().set(spec.getHttpProxy());
task.getHttpNoProxy().set(spec.getHttpNoProxy()); task.getHttpNoProxy().set(spec.getHttpNoProxy());
task.getHttpRewrites().set(spec.getHttpRewrites()); task.getHttpRewrites().set(spec.getHttpRewrites());
task.getExternalModuleReaders()
.set(providers.provider(() -> spec.getExternalModuleReaders().getAsMap()));
task.getExternalResourceReaders()
.set(providers.provider(() -> spec.getExternalResourceReaders().getAsMap()));
} }
private <T extends ModulesTask, S extends ModulesSpec> void configureModulesTask( private <T extends ModulesTask, S extends ModulesSpec> void configureModulesTask(
Project project, ProjectLayout layout,
ProviderFactory providers,
T task, T task,
S spec, S spec,
@Nullable TaskProvider<AnalyzeImportsTask> analyzeImportsTask, @Nullable TaskProvider<AnalyzeImportsTask> analyzeImportsTask,
@Nullable Transformer<List<?>, List<?>> mapSourceModules) { @Nullable Transformer<List<?>, List<?>> mapSourceModules) {
configureBaseTask(project, task, spec); configureBaseTask(layout, providers, task, spec);
if (mapSourceModules != null) { if (mapSourceModules != null) {
task.getSourceModules().set(spec.getSourceModules().map(mapSourceModules)); task.getSourceModules().set(spec.getSourceModules().map(mapSourceModules));
} else { } else {
@@ -513,21 +522,12 @@ public class PklPlugin implements Plugin<Project> {
} }
} }
private <T extends ModulesTask, S extends ModulesSpec> void configureModulesTask( private TaskProvider<AnalyzeImportsTask> createGatherImportsTask(
Project project,
T task,
S spec,
@Nullable TaskProvider<AnalyzeImportsTask> analyzeImportsTask) {
configureModulesTask(project, task, spec, analyzeImportsTask, null);
}
private TaskProvider<AnalyzeImportsTask> createAnalyzeImportsTask(
Project project, ModulesSpec spec) { Project project, ModulesSpec spec) {
var layout = project.getLayout();
var outputFile = var outputFile =
project layout.getBuildDirectory().file("pkl-gradle/imports/" + spec.getName() + ".json");
.getLayout() var providers = project.getProviders();
.getBuildDirectory()
.file("pkl-gradle/imports/" + spec.getName() + ".json");
return project return project
.getTasks() .getTasks()
.register( .register(
@@ -535,7 +535,8 @@ public class PklPlugin implements Plugin<Project> {
AnalyzeImportsTask.class, AnalyzeImportsTask.class,
task -> { task -> {
configureModulesTask( configureModulesTask(
project, layout,
providers,
task, task,
spec, spec,
null, null,
@@ -550,7 +551,10 @@ public class PklPlugin implements Plugin<Project> {
(it) -> (it) ->
it.getScheme() == null || it.getScheme().equalsIgnoreCase("file")) it.getScheme() == null || it.getScheme().equalsIgnoreCase("file"))
.toList()); .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.setGroup("build");
task.getOutputFormat().set(OutputFormat.JSON.toString()); task.getOutputFormat().set(OutputFormat.JSON.toString());
task.getOutputFile().set(outputFile); task.getOutputFile().set(outputFile);
@@ -570,20 +574,25 @@ public class PklPlugin implements Plugin<Project> {
*/ */
private <T extends ModulesTask> TaskProvider<T> createModulesTask( private <T extends ModulesTask> TaskProvider<T> createModulesTask(
Project project, Class<T> taskClass, ModulesSpec spec) { Project project, Class<T> taskClass, ModulesSpec spec) {
var analyzeImportsTask = createAnalyzeImportsTask(project, spec); var gatherImportsTask = createGatherImportsTask(project, spec);
var layout = project.getLayout();
var providers = project.getProviders();
return project return project
.getTasks() .getTasks()
.register( .register(
spec.getName(), spec.getName(),
taskClass, taskClass,
task -> configureModulesTask(project, task, spec, analyzeImportsTask)); task -> configureModulesTask(layout, providers, task, spec, gatherImportsTask, null));
} }
private <T extends BasePklTask> TaskProvider<T> createTask( private <T extends BasePklTask> TaskProvider<T> createTask(
Project project, Class<T> taskClass, BasePklSpec spec) { Project project, Class<T> taskClass, BasePklSpec spec) {
var layout = project.getLayout();
var providers = project.getProviders();
return project return project
.getTasks() .getTasks()
.register(spec.getName(), taskClass, task -> configureBaseTask(project, task, spec)); .register(
spec.getName(), taskClass, task -> configureBaseTask(layout, providers, task, spec));
} }
private <T> Set<T> append(Set<? extends T> set1, T element) { private <T> Set<T> append(Set<? extends T> set1, T element) {
@@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.net.URI;
import java.time.Duration; import java.time.Duration;
import org.gradle.api.NamedDomainObjectContainer;
import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.ListProperty;
@@ -59,4 +60,8 @@ public interface BasePklSpec {
ListProperty<String> getHttpNoProxy(); ListProperty<String> getHttpNoProxy();
MapProperty<URI, URI> getHttpRewrites(); MapProperty<URI, URI> getHttpRewrites();
NamedDomainObjectContainer<ExternalReaderSpec> getExternalModuleReaders();
NamedDomainObjectContainer<ExternalReaderSpec> getExternalResourceReaders();
} }
@@ -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<String> getExecutable();
@Input
public abstract ListProperty<String> getArguments();
public ExternalReader toExternalReader() {
return new ExternalReader(getExecutable().get(), getArguments().get());
}
public static Map<String, ExternalReader> toExternalReaderMap(
Collection<? extends ExternalReaderSpec> externalReaderSpecs) {
return externalReaderSpecs.stream()
.collect(
Collectors.toMap(ExternalReaderSpec::getName, ExternalReaderSpec::toExternalReader));
}
}
@@ -15,6 +15,8 @@
*/ */
package org.pkl.gradle.task; package org.pkl.gradle.task;
import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull;
import java.io.File; import java.io.File;
import org.gradle.api.file.RegularFileProperty; import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.Property; import org.gradle.api.provider.Property;
@@ -15,6 +15,9 @@
*/ */
package org.pkl.gradle.task; 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.io.File;
import java.net.URI; import java.net.URI;
import java.nio.file.Path; import java.nio.file.Path;
@@ -22,8 +25,6 @@ import java.nio.file.Paths;
import java.time.Duration; import java.time.Duration;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.inject.Inject; 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.InputFile;
import org.gradle.api.tasks.InputFiles; import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.Nested;
import org.gradle.api.tasks.Optional; import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.PathSensitive; import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity; import org.gradle.api.tasks.PathSensitivity;
@@ -50,6 +52,7 @@ import org.jspecify.annotations.Nullable;
import org.pkl.commons.cli.CliBaseOptions; import org.pkl.commons.cli.CliBaseOptions;
import org.pkl.core.Pair; import org.pkl.core.Pair;
import org.pkl.core.evaluatorSettings.Color; import org.pkl.core.evaluatorSettings.Color;
import org.pkl.gradle.spec.ExternalReaderSpec;
import org.pkl.gradle.utils.PluginUtils; import org.pkl.gradle.utils.PluginUtils;
@CacheableTask @CacheableTask
@@ -170,6 +173,12 @@ public abstract class BasePklTask extends DefaultTask {
@Optional @Optional
public abstract Property<Boolean> getPowerAssertions(); public abstract Property<Boolean> getPowerAssertions();
@Nested
public abstract MapProperty<String, ExternalReaderSpec> getExternalModuleReaders();
@Nested
public abstract MapProperty<String, ExternalReaderSpec> getExternalResourceReaders();
/** /**
* There are issues with using native libraries in Gradle plugins. As a workaround for now, make * There are issues with using native libraries in Gradle plugins. As a workaround for now, make
* Truffle use an un-optimized runtime. * Truffle use an un-optimized runtime.
@@ -224,8 +233,8 @@ public abstract class BasePklTask extends DefaultTask {
getHttpNoProxy().getOrElse(List.of()), getHttpNoProxy().getOrElse(List.of()),
getHttpRewrites().getOrNull(), getHttpRewrites().getOrNull(),
getHttpHeaders().getOrNull(), getHttpHeaders().getOrNull(),
Map.of(), toExternalReaderMap(getExternalModuleReaders().get().values()),
Map.of(), toExternalReaderMap(getExternalResourceReaders().get().values()),
null, null,
getPowerAssertions().getOrElse(false)); getPowerAssertions().getOrElse(false));
} }
@@ -248,16 +257,4 @@ public abstract class BasePklTask extends DefaultTask {
protected List<Pattern> patternsFromStrings(List<String> patterns) { protected List<Pattern> patternsFromStrings(List<String> patterns) {
return patterns.stream().map(Pattern::compile).collect(Collectors.toList()); return patterns.stream().map(Pattern::compile).collect(Collectors.toList());
} }
/**
* Equivalent to {@code provider.map(it -> f.apply(it)).getOrNull()}.
*
* <p>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 <T, U> @Nullable U mapAndGetOrNull(Provider<T> provider, Function<T, U> f) {
@Nullable T value = provider.getOrNull();
return value == null ? null : f.apply(value);
}
} }
@@ -15,6 +15,8 @@
*/ */
package org.pkl.gradle.task; package org.pkl.gradle.task;
import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull;
import java.io.File; import java.io.File;
import java.util.Collections; import java.util.Collections;
import java.util.Set; import java.util.Set;
@@ -15,13 +15,15 @@
*/ */
package org.pkl.gradle.task; 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.io.File;
import java.net.URI; import java.net.URI;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.gradle.api.InvalidUserDataException; import org.gradle.api.InvalidUserDataException;
import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.DirectoryProperty;
@@ -165,8 +167,8 @@ public abstract class ModulesTask extends BasePklTask {
List.of(), List.of(),
getHttpRewrites().getOrNull(), getHttpRewrites().getOrNull(),
getHttpHeaders().getOrNull(), getHttpHeaders().getOrNull(),
Map.of(), toExternalReaderMap(getExternalModuleReaders().get().values()),
Map.of(), toExternalReaderMap(getExternalResourceReaders().get().values()),
null, null,
getPowerAssertions().getOrElse(false)); getPowerAssertions().getOrElse(false));
} }
@@ -15,6 +15,8 @@
*/ */
package org.pkl.gradle.task; package org.pkl.gradle.task;
import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -15,6 +15,8 @@
*/ */
package org.pkl.gradle.task; package org.pkl.gradle.task;
import static org.pkl.gradle.utils.PluginUtils.mapAndGetOrNull;
import java.io.PrintWriter; import java.io.PrintWriter;
import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.provider.Property; import org.gradle.api.provider.Property;
@@ -25,13 +25,16 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.function.Function;
import org.gradle.api.InvalidUserDataException; import org.gradle.api.InvalidUserDataException;
import org.gradle.api.file.FileSystemLocation; import org.gradle.api.file.FileSystemLocation;
import org.gradle.api.file.RegularFile; 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.ImportGraph;
import org.pkl.core.util.IoUtils; import org.pkl.core.util.IoUtils;
public class PluginUtils { public final class PluginUtils {
private PluginUtils() {} private PluginUtils() {}
/** /**
@@ -160,4 +163,16 @@ public class PluginUtils {
"Failed to parse transitive imports from " + outputFile.getAsFile(), e); "Failed to parse transitive imports from " + outputFile.getAsFile(), e);
} }
} }
/**
* Equivalent to {@code provider.map(it -> f.apply(it)).getOrNull()}.
*
* <p>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 <T, U> @Nullable U mapAndGetOrNull(Provider<T> provider, Function<T, U> f) {
@Nullable T value = provider.getOrNull();
return value == null ? null : f.apply(value);
}
} }
@@ -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)
}
}