Nested Directives with Parameters (2)

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:

  1. Reads nested declaration metadata from current context.
  2. Binds incoming positional values by declared nested identifier name.
  3. 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 identifier i.
  • 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.

Leave a Comment