diff --git a/src/main/java/dev/jbang/cli/Init.java b/src/main/java/dev/jbang/cli/Init.java index 2d936e6ca8..7ba49f3596 100644 --- a/src/main/java/dev/jbang/cli/Init.java +++ b/src/main/java/dev/jbang/cli/Init.java @@ -116,6 +116,8 @@ public Integer doCall() throws IOException { propsMap.put("javaVersion", reqVersion); propsMap.put("compactSourceFiles", reqVersion >= 25); + validateTemplateExtension(tpl.fileRefs, outName); + List refTargets = tpl.fileRefs.entrySet() .stream() .map(e -> entry( @@ -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); } @@ -203,29 +207,64 @@ private void applyTemplateProperties(dev.jbang.catalog.Template tpl, Map fileRefs, String outName) { + String outExt = Util.extension(outName); + if (outExt.isEmpty()) { + return; + } + // Find the primary entry: first {filename}, else first {basename} + Map.Entry primary = null; + for (Map.Entry 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 propsMap) throws IOException { + renderQuteTemplate(outFile, templateRef, propsMap, null); + } + + void renderQuteTemplate(Path outFile, ResourceRef templateRef, Map propsMap, + ResourceResolver includeResolver) + throws IOException { Template template = TemplateEngine.instance().getTemplate(templateRef); if (template == null) { throw new ExitException(ExitException.EXIT_INVALID_INPUT, @@ -245,9 +284,13 @@ void renderQuteTemplate(Path outFile, ResourceRef templateRef, Map currentResolver = new ThreadLocal<>(); + TemplateEngine() { engine = Engine.builder() .addDefaults() @@ -33,13 +40,36 @@ public class TemplateEngine { * @return the optional reader */ private Optional 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 = Optional.empty(); diff --git a/src/test/java/dev/jbang/cli/TestInit.java b/src/test/java/dev/jbang/cli/TestInit.java index 077faf7f8b..a577106bd0 100644 --- a/src/test/java/dev/jbang/cli/TestInit.java +++ b/src/test/java/dev/jbang/cli/TestInit.java @@ -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 { @@ -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()); @@ -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")); + } + }