Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 62 additions & 19 deletions src/main/java/dev/jbang/cli/Init.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ public Integer doCall() throws IOException {
propsMap.put("javaVersion", reqVersion);
propsMap.put("compactSourceFiles", reqVersion >= 25);

validateTemplateExtension(tpl.fileRefs, outName);

List<RefTarget> refTargets = tpl.fileRefs.entrySet()
.stream()
.map(e -> entry(
Expand Down Expand Up @@ -166,7 +168,9 @@ public Integer doCall() throws IOException {
for (RefTarget refTarget : refTargets) {
if (refTarget.getSource().getOriginalResource().endsWith(".qute")) {
Path out = refTarget.to(outDir);
renderQuteTemplate(out, refTarget.getSource(), propsMap);
ResourceResolver includeResolver = ResourceResolver.combined(
tpl.catalog.catalogRef, ResourceResolver.forResources());
renderQuteTemplate(out, refTarget.getSource(), propsMap, includeResolver);
} else {
refTarget.copy(outDir);
}
Expand Down Expand Up @@ -203,29 +207,64 @@ private void applyTemplateProperties(dev.jbang.catalog.Template tpl, Map<String,
}
}

static Path resolveBaseName(String refTarget, String refSource, String outName) {
String result = refTarget;
if (dev.jbang.cli.Template.TPL_FILENAME_PATTERN.matcher(refTarget).find()
|| dev.jbang.cli.Template.TPL_BASENAME_PATTERN.matcher(refTarget).find()) {
String baseName = Util.base(outName);
String outExt = Util.extension(outName);
String targetExt = Util.extension(refTarget);
if (targetExt.isEmpty()) {
targetExt = refSource.endsWith(".qute") ? Util.extension(Util.base(refSource))
: Util.extension(refSource);
/**
* Validates that the user-supplied output name has an extension compatible with
* the template's primary file entry. The primary entry is the first
* {@code {filename}} entry, or if none exists, the first {@code {basename}}
* entry.
*/
static void validateTemplateExtension(Map<String, String> fileRefs, String outName) {
String outExt = Util.extension(outName);
if (outExt.isEmpty()) {
return;
}
// Find the primary entry: first {filename}, else first {basename}
Map.Entry<String, String> primary = null;
for (Map.Entry<String, String> e : fileRefs.entrySet()) {
if (dev.jbang.cli.Template.TPL_FILENAME_PATTERN.matcher(e.getKey()).find()) {
primary = e;
break;
}
if (!outExt.isEmpty() && !outExt.equals(targetExt)) {
throw new ExitException(ExitException.EXIT_INVALID_INPUT,
"Template expects " + targetExt + " extension, not " + outExt);
if (primary == null
&& dev.jbang.cli.Template.TPL_BASENAME_PATTERN.matcher(e.getKey()).find()) {
primary = e;
}
result = dev.jbang.cli.Template.TPL_FILENAME_PATTERN.matcher(result).replaceAll(outName);
result = dev.jbang.cli.Template.TPL_BASENAME_PATTERN.matcher(result).replaceAll(baseName);
}
if (primary == null) {
return;
}
String targetExt = Util.extension(primary.getKey());
if (targetExt.isEmpty()) {
String src = primary.getValue();
targetExt = src.endsWith(".qute") ? Util.extension(Util.base(src))
: Util.extension(src);
}
if (!outExt.equals(targetExt)) {
throw new ExitException(ExitException.EXIT_INVALID_INPUT,
"Template expects " + targetExt + " extension, not " + outExt);
}
}

/**
* Substitutes {filename} and {basename} placeholders in a file-ref target name.
* No extension validation — that is handled by
* {@link #validateTemplateExtension}.
*/
static Path resolveBaseName(String refTarget, String refSource, String outName) {
String result = refTarget;
result = dev.jbang.cli.Template.TPL_FILENAME_PATTERN.matcher(result).replaceAll(outName);
result = dev.jbang.cli.Template.TPL_BASENAME_PATTERN.matcher(result).replaceAll(Util.base(outName));
return Paths.get(result);
}

void renderQuteTemplate(Path outFile, ResourceRef templateRef, Map<String, Object> propsMap)
throws IOException {
renderQuteTemplate(outFile, templateRef, propsMap, null);
}

void renderQuteTemplate(Path outFile, ResourceRef templateRef, Map<String, Object> propsMap,
ResourceResolver includeResolver)
throws IOException {
Template template = TemplateEngine.instance().getTemplate(templateRef);
if (template == null) {
throw new ExitException(ExitException.EXIT_INVALID_INPUT,
Expand All @@ -245,9 +284,13 @@ void renderQuteTemplate(Path outFile, ResourceRef templateRef, Map<String, Objec
TemplateInstance templateWithData = template.instance();
propsMap.forEach(templateWithData::data);
Util.verboseMsg("Rendering template: " + templateRef + " with properties: " + propsMap);
String result = templateWithData.render();

writer.write(result);
TemplateEngine.instance().setIncludeResolver(includeResolver);
try {
String result = templateWithData.render();
writer.write(result);
} finally {
TemplateEngine.instance().clearIncludeResolver();
}
outFile.toFile().setExecutable(true);
}
}
Expand Down
32 changes: 31 additions & 1 deletion src/main/java/dev/jbang/util/TemplateEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.Optional;

import dev.jbang.resources.ResourceRef;
import dev.jbang.resources.ResourceResolver;

import io.quarkus.qute.Engine;
import io.quarkus.qute.ReflectionValueResolver;
Expand All @@ -18,6 +19,12 @@ public class TemplateEngine {

static TemplateEngine instance;

/**
* Per-thread resolver used by the Qute locator to resolve {#include} paths. Set
* before rendering, cleared after.
*/
private static final ThreadLocal<ResourceResolver> currentResolver = new ThreadLocal<>();

TemplateEngine() {
engine = Engine.builder()
.addDefaults()
Expand All @@ -33,13 +40,36 @@ public class TemplateEngine {
* @return the optional reader
*/
private Optional<TemplateLocator.TemplateLocation> locate(String ref) {
return Optional.of(new ResourceRefTemplateLocation(ResourceRef.forResource(ref)));
ResourceResolver resolver = currentResolver.get();
ResourceRef resourceRef = resolver != null ? resolver.resolve(ref) : ResourceRef.forResource(ref);
if (resourceRef == null || !resourceRef.exists()) {
return Optional.empty();
}
return Optional.of(new ResourceRefTemplateLocation(resourceRef));
}

public Template getTemplate(ResourceRef templateRef) {
return engine.getTemplate(templateRef.getOriginalResource());
}

/**
* Returns the template, using the given resolver to resolve any {#include}
* directives found within the template.
*/
/**
* Sets a resolver for {#include} resolution during template rendering. Call
* before render(), clear with {@link #clearIncludeResolver()} after.
*/
public void setIncludeResolver(ResourceResolver resolver) {
if (resolver != null) {
currentResolver.set(resolver);
}
}

public void clearIncludeResolver() {
currentResolver.remove();
}

static class ResourceRefTemplateLocation implements TemplateLocator.TemplateLocation {
private final ResourceRef resourceRef;
private Optional<Variant> variant = Optional.empty();
Expand Down
114 changes: 114 additions & 0 deletions src/test/java/dev/jbang/cli/TestInit.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import dev.jbang.ai.AIProvider;
import dev.jbang.ai.AIProviderFactory;
import dev.jbang.resources.ResourceRef;
import dev.jbang.resources.ResourceResolver;
import dev.jbang.util.Util;

public class TestInit extends BaseTest {
Expand Down Expand Up @@ -218,6 +219,11 @@ void testInitMultipleFilesWrongName() throws IOException {
testFailMultipleFiles("{filename}", "edit.md", "edit.java", true, 2);
}

@Test
void testInitMultipleFilesWrongNameBasename() throws IOException {
testFailMultipleFiles("{basename}.java", "edit.md", "edit.java", true, 2);
}

void testInitMultipleFiles(String targetName, String initName, String outName, boolean abs) throws IOException {
Path outFile = setupInitMultipleFiles(targetName, initName, abs);
int result = JBang.execute("init", "-t=name", outFile.toString());
Expand Down Expand Up @@ -356,4 +362,112 @@ void testInitPropertiesIgnoringPropertyDefaults() throws IOException {

}

@Test
void testInitUsingTemplateWithFilenameAndBasename() throws IOException {
Path cwd = Util.getCwd();
Path javaFileQute = Files.write(cwd.resolve("file1.java.qute"), "Hello World".getBytes());
Path tfFileQute = Files.write(cwd.resolve("file2.tf.qute"), "Hello World from .tf file".getBytes());
Path outJava = cwd.resolve("result.java");
Path outTf = cwd.resolve("prefixed-result.tf");

JBang.execute("template", "add", "-f", cwd.toString(), "--name=template-with-more-files",
"{filename}" + "=" + javaFileQute.toAbsolutePath(),
"prefixed-{basename}.tf" + "=" + tfFileQute.toAbsolutePath());

assertThat(outJava.toFile().exists(), not(true));

int result = JBang.execute("init", "--verbose", "--template=template-with-more-files",
outJava.toAbsolutePath().toString());

assertThat(result, is(0));
assertThat(outJava.toFile().exists(), is(true));
assertThat(outTf.toFile().exists(), is(true));

String javaContent = Util.readString(outJava);
String tfContent = Util.readString(outTf);

assertThat(javaContent, containsString("Hello World"));
assertThat(tfContent, containsString("Hello World from .tf file"));
}

@Test
void testInitUsingTemplateWithOnlyBasenameEntries() throws IOException {
Path cwd = Util.getCwd();
Path javaFileQute = Files.write(cwd.resolve("file1.java.qute"), "Hello Java".getBytes());
Path mdFileQute = Files.write(cwd.resolve("file2.md.qute"), "Hello Markdown".getBytes());
Path outJava = cwd.resolve("result.java");
Path outMd = cwd.resolve("result.md");

JBang.execute("template", "add", "-f", cwd.toString(), "--name=basename-only",
"{basename}.java" + "=" + javaFileQute.toAbsolutePath(),
"{basename}.md" + "=" + mdFileQute.toAbsolutePath());

int result = JBang.execute("init", "--template=basename-only",
outJava.toAbsolutePath().toString());

assertThat(result, is(0));
assertThat(outJava.toFile().exists(), is(true));
assertThat(outMd.toFile().exists(), is(true));
}

@Test
void testInitUsingTemplateWithBasenameWrongExtension() throws IOException {
Path cwd = Util.getCwd();
Path javaFileQute = Files.write(cwd.resolve("file1.java.qute"), "Hello Java".getBytes());
Path outFile = cwd.resolve("result.py");

JBang.execute("template", "add", "-f", cwd.toString(), "--name=basename-ext-check",
"{basename}.java" + "=" + javaFileQute.toAbsolutePath());

int result = JBang.execute("init", "--template=basename-ext-check",
outFile.toAbsolutePath().toString());

assertThat(result, is(2));
}

@Test
void testQuteIncludeResolvesRelativeTemplates() throws IOException {
Path cwd = Util.getCwd();
Path subDir = Files.createDirectory(cwd.resolve("tpls"));
Files.write(subDir.resolve("main.txt.qute"), "{#include tpls/header.txt.qute /}BODY".getBytes());
Files.write(subDir.resolve("header.txt.qute"), "HEADER\n".getBytes());

Path out = cwd.resolve("result.txt");
ResourceRef ref = ResourceRef.forFile(subDir.resolve("main.txt.qute"));
ResourceResolver resolver = ResourceResolver.forResources();
new Init().renderQuteTemplate(out, ref, new HashMap<>(), resolver);

String content = Util.readString(out);
assertThat(content, containsString("HEADER"));
assertThat(content, containsString("BODY"));
}

@Test
void testQuteIncludeFailsWithoutResolver() throws IOException {
Path cwd = Util.getCwd();
Path subDir = Files.createDirectory(cwd.resolve("tpls2"));
Files.write(subDir.resolve("main.txt.qute"), "{#include nonexistent/missing.qute /}BODY".getBytes());

Path out = cwd.resolve("result2.txt");
ResourceRef ref = ResourceRef.forFile(subDir.resolve("main.txt.qute"));
// No resolver — include can't be found, should fail
assertThrows(Exception.class,
() -> new Init().renderQuteTemplate(out, ref, new HashMap<>(), null));
}

@Test
void testResolveBaseNameSubstitution() {
// {filename} resolves to full output name
Path path = Init.resolveBaseName("{filename}", "template.java", "result.java");
assertThat(path.toString(), is("result.java"));

// {basename} with different extension just substitutes
path = Init.resolveBaseName("prefix-{basename}.tf", "template.tf", "result.java");
assertThat(path.toString(), is("prefix-result.tf"));

// no placeholder = passthrough
path = Init.resolveBaseName("static-file.txt", "template.txt", "result.java");
assertThat(path.toString(), is("static-file.txt"));
}

}
Loading