FreshMarker now supports passing parameters into Nested directives inside Macro directives. The previous article discussed the new feature and its capabilities. This post focuses on the technical implementation and outlines the necessary changes from the parser to runtime.
Grammar: nested accepts positional arguments
The grammar for NestedInstruction allows an optional positional argument list after nested:
Nested #NestedInstruction : (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>) <NESTED> [<BLANK> PositionalArgsList] LooseTagEnd ;
The important part is the optional PositionalArgsList, which gives syntax support for calls like:
<#nested title, item.name, item?index />
Building the AST fragments: nested args are preserved
Parsing positional args robustly
PositionalArgsListBuilder was adapted so it walks nodes and ignores commas, instead of relying on fixed index stepping:
@Override
public List<TemplateObject> visit(PositionalArgsList expression, List<TemplateObject> input) {
for (Node node : expression) {
if (node.getType() != Token.TokenType.COMMA) {
input.add(node.accept(interpolationBuilder, null));
}
}
return input;
}
Creating NestedInstructionFragment with parameters
FragmentBuilder now creates a parameterized nested fragment when arguments are present:
@Override
public List<Fragment> visit(NestedInstruction ftl, List<Fragment> input) {
if (ftl.size() > 3) {
List<TemplateObject> parameters =
ftl.get(3).accept(new PositionalArgsListBuilder(interpolationBuilder), new ArrayList<>());
input.add(new NestedInstructionFragment(parameters));
} else {
input.add(NESTED_INSTRUCTION_FRAGMENT);
}
return input;
}
So Nested directives now carry argument expressions into runtime execution.
Context model shift: nested content is no longer fragment-only
Previously, nested content access was effectively fragment-only. Now, the environment returns a richer container NestedContextHolder.
public interface Environment {
Optional<NestedContextHolder> getNestedContent();
// ...
}
The NestedContextHolder is a record which includes both body and parameter declaration metadata.
public record NestedContextHolder(Fragment fragment, List<ParameterHolder> nestedIdentifier) { }
This is the key model improvement: runtime can resolve both what to execute and how to bind arguments.
Runtime execution: bind nested args in a dedicated environment
NestedInstructionFragment now wraps execution in a specialized environment:
@Override
public void process(ProcessContext context) {
Environment oldEnvironment = context.getEnvironment();
try {
oldEnvironment.getNestedContent().ifPresent(n -> {
Environment environment = new NestedInstructionEnvironment(parameters, n.nestedIdentifier(), oldEnvironment, context);
context.setEnvironment(environment);
n.fragment().process(context);
});
} finally {
context.setEnvironment(oldEnvironment);
}
}
This design does three things cleanly:
- Reads nested declaration metadata from current context.
- Binds incoming positional values by declared nested identifier name.
- Restores original environment safely via
try/finally.
Parameter binding logic in NestedInstructionEnvironment
The binding behavior is straightforward and explicit:
- If caller provides value at index
i, bind it to nested identifieri. - If missing, use nested default value (if any).
- If no default exists, bind
TemplateNull.NULL. - Name lookup prefers local bound values, then delegates to wrapped environment.
for (int i = 0; i < parameterHolders.size(); i++) {
ParameterHolder parameterHolder = parameterHolders.get(i);
if (parameters.size() > i) {
TemplateObject templateObject = parameters.get(i);
if (!(templateObject instanceof LooperVariable)) {
templateObject = templateObject.evaluateToObject(context);
}
values.put(parameterHolder.name(), templateObject);
} else {
TemplateObject defaultValue = parameterHolder.defaultValue();
values.put(parameterHolder.name(), Objects.requireNonNullElse(defaultValue, TemplateNull.NULL));
}
}
And lookup override:
@Override
public TemplateObject getValue(String name) {
if (values.containsKey(name)) {
return values.get(name);
}
return wrapped.getValue(name);
}
Why LooperVariable is treated specially
A subtle but important runtime detail: loop variables should not always be eagerly evaluated in nested binding.
If they are flattened too early, loop semantics can break in nested contexts.
That is why the binder keeps LooperVariable as-is and evaluates regular expressions only:
if (!(templateObject instanceof LooperVariable)) {
templateObject = templateObject.evaluateToObject(context);
}
This preserves expected behavior when nested parameters are used inside iteration constructs.
Directive/macro wiring: nested identifiers flow through execution
To make the whole mechanism work, directive execution needed metadata propagation. UserDirectiveFragment now carries nested identifier declarations:
public class UserDirectiveFragment implements Fragment {
private final NameSpaced nameSpaced;
private final Map<String, TemplateObject> namedArgs;
private final List<ParameterHolder> nestedIdentifier;
private final Fragment body;
public UserDirectiveFragment(NameSpaced nameSpaced,
Map<String, TemplateObject> namedArgs,
List<ParameterHolder> nestedIdentifier,
Fragment body) {
this.nameSpaced = nameSpaced;
this.namedArgs = namedArgs;
this.nestedIdentifier = nestedIdentifier;
this.body = body;
}
}
MacroEnvironment exposes nested content with declaration metadata:
@Override
public Optional<NestedContextHolder> getNestedContent() {
return Optional.of(new NestedContextHolder(body, nestedIdentifier));
}
This closes the chain:
directive definition nested declaration nested invocation with values coped binding at runtime
Conceptual before/after
Before
- Nested had no explicit parameter channel.
- Nested blocks relied on parent scope variables.
- Contract between directive author and caller was implicit.
After
- Nested accepts positional values.
- Directive defines nested identifiers (with optional defaults).
- Runtime creates a dedicated binding environment for nested execution.
Closing note
The implementation is intentionally layered:
- syntax extension in grammar,
- argument extraction in fragment building,
- metadata-carrying nested context contract,
- scoped runtime binding with safe fallback and loop-aware behavior.
That makes the feature feel small from the outside, while staying consistent with FreshMarker’s internal architecture.