Skip to content

Commit

Permalink
Qute - support optional end tags for sections
Browse files Browse the repository at this point in the history
- resolves #33293
  • Loading branch information
mkouba committed May 11, 2023
1 parent 936737b commit 0fbcda5
Show file tree
Hide file tree
Showing 10 changed files with 179 additions and 31 deletions.
49 changes: 49 additions & 0 deletions docs/src/main/asciidoc/qute-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Sections::
A <<sections,section>> 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::
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,11 @@ public Scope initializeBlock(Scope outerScope, BlockInfo block) {
return delegate.initializeBlock(outerScope, block);
}

@Override
public MissingEndTagStrategy missingEndTagStrategy() {
return delegate.missingEndTagStrategy();
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,9 +80,6 @@ class Parser implements ParserHelper, ParserDelegate, WithOrigin, ErrorInitializ
private final List<Function<String, String>> 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> variant) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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++;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ public enum ParserError implements ErrorCode {
*/
UNTERMINATED_SECTION,

/**
* <code>{name</code>
*/
UNTERMINATED_EXPRESSION,

/**
* <code>{#if (foo || bar}{/}</code>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Expression> params = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

}

0 comments on commit 0fbcda5

Please sign in to comment.