From 0fbcda584fc81cc850da1913e85c28424e1c5969 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Thu, 11 May 2023 09:55:17 +0200 Subject: [PATCH] Qute - support optional end tags for sections - resolves #33293 --- docs/src/main/asciidoc/qute-reference.adoc | 49 +++++++++++ .../java/io/quarkus/qute/EngineBuilder.java | 5 ++ .../io/quarkus/qute/IncludeSectionHelper.java | 5 ++ .../src/main/java/io/quarkus/qute/Parser.java | 87 ++++++++++++------- .../java/io/quarkus/qute/ParserError.java | 5 ++ .../io/quarkus/qute/SectionHelperFactory.java | 25 ++++++ .../io/quarkus/qute/SetSectionHelper.java | 5 ++ .../test/java/io/quarkus/qute/EvalTest.java | 2 +- .../java/io/quarkus/qute/IncludeTest.java | 12 +++ .../java/io/quarkus/qute/SetSectionTest.java | 15 ++++ 10 files changed, 179 insertions(+), 31 deletions(-) diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index 036530d2665a3..8d6fe3f3195c4 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -150,6 +150,7 @@ Sections:: A <> may contain static text, expressions and nested sections: `{#if foo.active}{foo.name}{/if}`. The name in the closing tag is optional: `{#if active}ACTIVE!{/}`. A section can be empty: `{#myTag image=true /}`. +Some sections support optional end tags, i.e. if the end tag is missing then the section ends where the parent section ends. A section may also declare nested section blocks: `{#if item.valid} Valid. {#else} Invalid. {/if}` and decide which block to render. Unparsed Character Data:: @@ -585,6 +586,54 @@ A section has a start tag that starts with `#`, followed by the name of the sect It may be empty, i.e. the start tag ends with `/`: `{#myEmptySection /}`. Sections usually contain nested expressions and other sections. The end tag starts with `/` and contains the name of the section (optional): `{#if foo}Foo!{/if}` or `{#if foo}Foo!{/}`. +Some sections support optional end tags, i.e. if the end tag is missing then the section ends where the parent section ends. + +.`#let` Optional End Tag Example +[source,html] +---- +{#if item.isActive} + {#let price = item.price} <1> + {price} + // synthetic {/let} added here automatically +{/if} +// {price} cannot be used here! +---- +<1> Defines the local variable that can be used inside the parent `{#if}` section. + +|=== +|Built-in section |Supports Optional End Tag + +|`{#for}` +|❌ + +|`{#if}` +|❌ + +|`{#when}` +|❌ + +|`{#let}` +|✅ + +|`{#with}` +|❌ + +|`{#include}` +|✅ + +|User-defined Tags +|❌ + +|`{#fragment}` +|❌ + +|`{#cached}` +|❌ + +|=== + +[[sections_params]] +==== Parameters A start tag can define parameters with optional names, e.g. `{#if item.isActive}` and `{#let foo=1 bar=false}`. Parameters are separated by one or more spaces. diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/EngineBuilder.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/EngineBuilder.java index 713ee3db6abea..ae7c5dc065e94 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/EngineBuilder.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/EngineBuilder.java @@ -332,6 +332,11 @@ public Scope initializeBlock(Scope outerScope, BlockInfo block) { return delegate.initializeBlock(outerScope, block); } + @Override + public MissingEndTagStrategy missingEndTagStrategy() { + return delegate.missingEndTagStrategy(); + } + } } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/IncludeSectionHelper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/IncludeSectionHelper.java index a4438a030fae9..8edfcf3df81b3 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/IncludeSectionHelper.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/IncludeSectionHelper.java @@ -107,6 +107,11 @@ public ParametersInfo getParameters() { return builder.build(); } + @Override + public MissingEndTagStrategy missingEndTagStrategy() { + return MissingEndTagStrategy.BIND_TO_PARENT; + } + @Override protected boolean ignoreParameterInit(String key, String value) { return key.equals(TEMPLATE) diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java index 7f8d58a8bbbd6..dad999d56c211 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java @@ -28,6 +28,7 @@ import io.quarkus.qute.Expression.Part; import io.quarkus.qute.SectionHelperFactory.BlockInfo; +import io.quarkus.qute.SectionHelperFactory.MissingEndTagStrategy; import io.quarkus.qute.SectionHelperFactory.ParametersInfo; import io.quarkus.qute.SectionHelperFactory.ParserDelegate; import io.quarkus.qute.TemplateNode.Origin; @@ -79,9 +80,6 @@ class Parser implements ParserHelper, ParserDelegate, WithOrigin, ErrorInitializ private final List> contentFilters; private boolean hasLineSeparator; - // The number of param declarations with default values for which a synthetic {#let} section was added - private int paramDeclarationDefaults; - private TemplateImpl template; public Parser(EngineImpl engine, Reader reader, String templateId, String generatedId, Optional variant) { @@ -155,33 +153,40 @@ Template parse() { // Flush the last text segment flushText(); } else { - String reason; - ErrorCode code; + String reason = null; + ErrorCode code = null; if (state == State.TAG_INSIDE_STRING_LITERAL) { reason = "unterminated string literal"; code = ParserError.UNTERMINATED_STRING_LITERAL; } else if (state == State.TAG_INSIDE) { - reason = "unterminated section"; - code = ParserError.UNTERMINATED_SECTION; + // First handle the optional end tags and if an unterminated section is found the then throw an exception + SectionNode.Builder section = sectionStack.peek(); + if (!section.helperName.equals(ROOT_HELPER_NAME)) { + SectionNode.Builder unterminated = handleOptionalEngTags(section, ROOT_HELPER_NAME); + if (unterminated != null) { + reason = "unterminated section"; + code = ParserError.UNTERMINATED_SECTION; + } + } else { + reason = "unterminated expression"; + code = ParserError.UNTERMINATED_EXPRESSION; + } } else { reason = "unexpected state [" + state + "]"; code = ParserError.GENERAL_ERROR; } - throw error(code, - "unexpected non-text buffer at the end of the template - {reason}: {buffer}") - .argument("reason", reason) - .argument("buffer", buffer) - .build(); + if (code != null) { + throw error(code, + "unexpected non-text buffer at the end of the template - {reason}: {buffer}") + .argument("reason", reason) + .argument("buffer", buffer) + .build(); + } } } - // Param declarations with default values - a synthetic {#let} section has no end tag, i.e. {/let} so we need to handle this specially - for (int i = 0; i < paramDeclarationDefaults; i++) { - SectionNode.Builder section = sectionStack.pop(); - sectionStack.peek().currentBlock().addNode(section.build(this::currentTemplate)); - // Remove the last type info map from the stack - scopeStack.pop(); - } + // Note that this also handles the param declarations with default values, i.e. synthetic {#let} sections + handleOptionalEngTags(sectionStack.peek(), ROOT_HELPER_NAME); SectionNode.Builder root = sectionStack.peek(); if (root == null) { @@ -506,7 +511,8 @@ private void sectionEnd(String content, String tag) { SectionNode.Builder section = sectionStack.peek(); SectionBlock.Builder block = section.currentBlock(); String name = content.substring(1, content.length()); - if (block != null && !block.getLabel().equals(SectionHelperFactory.MAIN_BLOCK_NAME) + if (block != null + && !block.getLabel().equals(SectionHelperFactory.MAIN_BLOCK_NAME) && !section.helperName.equals(name)) { // Non-main block end, e.g. {/else} if (!name.isEmpty() && !block.getLabel().equals(name)) { @@ -517,18 +523,23 @@ private void sectionEnd(String content, String tag) { } section.endBlock(); } else { - // Section end, e.g. {/if} + // Section end, e.g. {/if} or {/} if (section.helperName.equals(ROOT_HELPER_NAME)) { throw error(ParserError.SECTION_START_NOT_FOUND, "section start tag found for {tag}") .argument("tag", tag) .build(); } if (!name.isEmpty() && !section.helperName.equals(name)) { - throw error(ParserError.SECTION_END_DOES_NOT_MATCH_START, - "section end tag [{name}] does not match the start tag [{tag}]") - .argument("name", name) - .argument("tag", section.helperName) - .build(); + // The tag name is not empty but does not match the current section + // First handle the optional end tags and if an unterminated section is found the then throw an exception + SectionNode.Builder unterminated = handleOptionalEngTags(section, name); + if (unterminated != null) { + throw error(ParserError.SECTION_END_DOES_NOT_MATCH_START, + "section end tag [{name}] does not match the start tag [{tag}]") + .argument("name", name) + .argument("tag", unterminated.helperName) + .build(); + } } // Pop the section and its main block section = sectionStack.pop(); @@ -539,6 +550,25 @@ private void sectionEnd(String content, String tag) { scopeStack.pop(); } + /** + * + * @param section + * @return an unterminated section or {@code null} if no unterminated section was detected + */ + private SectionNode.Builder handleOptionalEngTags(SectionNode.Builder section, String name) { + while (section != null && !section.helperName.equals(name)) { + if (section.factory.missingEndTagStrategy() == MissingEndTagStrategy.BIND_TO_PARENT) { + section = sectionStack.pop(); + sectionStack.peek().currentBlock().addNode(section.build(this::currentTemplate)); + scopeStack.pop(); + section = sectionStack.peek(); + } else { + return section; + } + } + return null; + } + private void parameterDeclaration(String content, String tag) { Scope currentScope = scopeStack.peek(); @@ -614,14 +644,11 @@ private void parameterDeclaration(String content, String tag) { List.of(key + "?=" + defaultValue).iterator(), sectionNode.currentBlock()); - // Init section block + // Init a synthetic section block currentScope = scopeStack.peek(); Scope newScope = factory.initializeBlock(currentScope, sectionNode.currentBlock()); scopeStack.addFirst(newScope); sectionStack.addFirst(sectionNode); - - // A synthetic {#let} section has no end tag, i.e. {/let} so we need to handle this specially - paramDeclarationDefaults++; } } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ParserError.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ParserError.java index a246ae6139938..b4cd36cbb02f3 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ParserError.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ParserError.java @@ -59,6 +59,11 @@ public enum ParserError implements ErrorCode { */ UNTERMINATED_SECTION, + /** + * {name + */ + UNTERMINATED_EXPRESSION, + /** * {#if (foo || bar}{/} */ diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java index f2f062e9018f5..aa165b207d0ea 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java @@ -118,6 +118,31 @@ default Scope initializeBlock(Scope outerScope, BlockInfo block) { return outerScope; } + /** + * A section end tag may be mandatory or optional. + * + * @return the strategy + */ + default MissingEndTagStrategy missingEndTagStrategy() { + return MissingEndTagStrategy.ERROR; + } + + /** + * This strategy is used when an unterminated section is detected during parsing. + */ + public enum MissingEndTagStrategy { + + /** + * The end tag is mandatory. A missing end tag results in a parser error. + */ + ERROR, + + /** + * The end tag is optional. The section ends where the parent section ends. + */ + BIND_TO_PARENT; + } + interface ParserDelegate extends ErrorInitializer { default TemplateException createParserError(String message) { diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SetSectionHelper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SetSectionHelper.java index 5d70b8444e6cd..7cd5901b980e1 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SetSectionHelper.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SetSectionHelper.java @@ -115,6 +115,11 @@ public ParametersInfo getParameters() { return ParametersInfo.EMPTY; } + @Override + public MissingEndTagStrategy missingEndTagStrategy() { + return MissingEndTagStrategy.BIND_TO_PARENT; + } + @Override public SetSectionHelper initialize(SectionInitContext context) { Map params = new HashMap<>(); diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/EvalTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/EvalTest.java index 7b33224b25a50..e1ef1b4f59c44 100644 --- a/independent-projects/qute/core/src/test/java/io/quarkus/qute/EvalTest.java +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/EvalTest.java @@ -37,7 +37,7 @@ public void testInvalidTemplateContents() { assertThatExceptionOfType(TemplateException.class) .isThrownBy(() -> Engine.builder().addDefaults().build().parse("{#eval invalid /}").data("invalid", "{foo") .render()) - .withMessageContainingAll("Parser error in the evaluated template", "unterminated section"); + .withMessageContainingAll("Parser error in the evaluated template", "unterminated expression"); } } diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/IncludeTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/IncludeTest.java index 1848e70789c8a..e8b762996d2ad 100644 --- a/independent-projects/qute/core/src/test/java/io/quarkus/qute/IncludeTest.java +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/IncludeTest.java @@ -222,7 +222,19 @@ public void testInvalidFragment() { assertEquals( "Rendering error in template [bum.html] line 1: invalid fragment identifier [foo-and_bar]", expected.getMessage()); + } + + @Test + public void testOptionalEndTag() { + Engine engine = Engine.builder().addDefaults().build(); + engine.putTemplate("super", engine.parse("{#insert header}default header{/insert}::{#insert}{/}")); + assertEquals("super header:: body", + engine.parse("{#include super}{#header}super header{/header} body").render()); + assertEquals("super header:: 1", + engine.parse("{#let foo = 1}{#include super}{#header}super header{/header} {foo}").render()); + assertEquals("default header:: 1", + engine.parse("{#include super}{#let foo=1} {foo}").render()); } } diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/SetSectionTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/SetSectionTest.java index bc1b70a000c61..6e45c6c740896 100644 --- a/independent-projects/qute/core/src/test/java/io/quarkus/qute/SetSectionTest.java +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/SetSectionTest.java @@ -74,4 +74,19 @@ public void testCompositeParams() { .render()); } + @Test + public void testOptionalEndTag() { + Engine engine = Engine.builder().addDefaults().build(); + assertEquals("true", + engine.parse("{#let foo=true}{foo}").render()); + assertEquals("true ?", + engine.parse("{#let foo=true}{foo} ?").render()); + assertEquals("true::1", + engine.parse("{#let foo=true}{#let bar = 1}{foo}::{bar}").render()); + assertEquals("true", + engine.parse("{#let foo=true}{#if baz}{foo}{/}").data("baz", true).render()); + assertEquals("true::null", + engine.parse("{#if baz}{#let foo=true}{foo}{/if}::{foo ?: 'null'}").data("baz", true).render()); + } + }