Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Qute - support optional end tags for sections #33296

Merged
merged 1 commit into from
May 11, 2023
Merged
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
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose found the then throw is a typo? But it's not worth re-running CI for that...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right 🤦

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());
}

}