FreshMarker Sorted Hash Lists

“Anyway, if you stop tellin’ people it’s all sorted out after they’re dead, they might try sorting it all out while they’re alive.”

Terry Pratchett

I really like it when I can add new features to FreshMarker, especially if they are easy to implement and integrate elegantly into the overall system.

Like its inspiration FreeMarker, FreshMarker supports the List directive. The List directive applies a sequence of data to a template fragment. The sequence can originate from a list, a range or a hash. Details on the possibilities of the directive can be found in the user manual.

A hash is a data structure in which a value is stored for a name. The simplest way to create a hash for FreshMarker is a Map. Another simple option is a Java Bean or a non-local Record.

The philosophy of FreshMarker is to provide simple solutions to problems. When using a list directive on a hash, the order results from the quirks of the underlying data structure.

With a Java Bean, the Java Bean Inspector defines the order of the properties. In the case of a Record, its defined by the order of the attributes in the constructor and in the case of a Map it depends on its implementation. A HashMap does not offer a guaranteed sequence, but the implementations of SequencedMap do.

The following template fragment lists the content of the template variable hash. The variables k and v contain the content of current key and value. The optional variable l is the loop variable which, among other things, provides the counter with the current line number.

<#list hash as k, v with l>
${l?counter}. ${k} => ${v}
</#list>

If I apply this template to the content of the system properties, I get the following (shortened and censored) output.

1. java.specification.version => 21
2. sun.cpu.isalist => ...
3. sun.jnu.encoding => ...
4. java.class.path => ...
5. java.vm.vendor => Oracle Corporation
6. sun.arch.data.model => 64
7. idea.test.cyclic.buffer.size => 1048576
8. user.variant => 
9. java.vendor.url => https://java.oracle.com/
10. user.timezone => Europe/Berlin

This output is not sorted alphabetically. If I want the list to be sorted alphabetically, I can put the system properties in a TreeMap, for example. Then the keys of the Map are run through in alphabetical order.

Sometimes, however, it is not possible to change the order in advance. The order of the keys of a Java Bean and a Record are fixed. It is also ugly and complex to reorganise various data structures in advance in order to obtain an alphabetically sorted map.

The alternative to changing the data is to extend the template engine to include sorting for hash lists. The following syntax allows the keys to be sorted alphabetically in ascending or descending order.

<#list hash as k sorted asc, v with l>
${l?counter}. ${k} => ${v}
</#list>

<#list hash as k sorted desc, v with l>
${l?counter}. ${k} => ${v}
</#list>

In order to supplement the syntax of the template engine, the CongoCC grammar for FreshMarker must first be supplemented.

List #ListInstruction :
    (<FTL_DIRECTIVE_OPEN1>|<FTL_DIRECTIVE_OPEN2>)
    <LIST><BLANK>
    Expression
    <AS>
    <IDENTIFIER>
    [
      [ <SORTED> (<ASCENDING> | <DESCENDING>) ]
      <COMMA> <IDENTIFIER>
    ]
    [ <WITH> <IDENTIFIER> ]
    <CLOSE_TAG>
    Block
    DirectiveEnd("list")
;

To do this, it is only necessary to insert the selected line into the rule for the list. An optional sorting for the key can only be specified if the additional identifier for the value from a hash is specified.

During the evaluation in the syntax tree visitor, the presence of the sorting token must be checked in order to provide a Comparator for String. Comparator.naturalOrder() and Comparator.reverseOrder() are stored in a Map for the DESCNDING and ASCENDING tokens.

public BlockFragment visit(ListInstruction ftl, BlockFragment input) {
  TemplateObject list = ftl.getChild(3).accept(InterpolationBuilder.INSTANCE, null);
  String identifier = ftl.getChild(5).toString();
  int index = 6;
  Comparator<String> comparator = null;
  if (ftl.get(index).getType() == TokenType.SORTED) {
    comparator = COMPARATORS.get((TokenType) ftl.get(index + 1).getType());;
    index += 2;
  }
  String valueIdentifier = null;
  if (ftl.getChild(index).getType() == TokenType.COMMA) {
    valueIdentifier = ftl.getChild(index + 1).toString();
    index +=2;
  }
  String looperIdentifier = null;
  if (ftl.getChild(index).getType() == TokenType.WITH) {
    looperIdentifier = ((IDENTIFIER) ftl.getChild(index + 1)).toString();
    index += 2;
  }
  BlockFragment block = ftl.getChild(index + 1).accept(this, new BlockFragment());
  if (valueIdentifier != null) {
    input.addFragment(new HashListFragment(list, identifier, valueIdentifier, looperIdentifier, block, ftl, comparator));
  } else {
    input.addFragment(new SequenceListFragment(list, identifier, looperIdentifier, block, ftl));
  }
  return input;
}

At the end of the method, the Comparator is passed to the HashListFragment and the Comparator is applied in its process method when providing the values for the list.

public void process(ProcessContext context) {
  try {
    Map<String, Object> map = ((TemplateMap) list.evaluateToObject(context)).map();
    List<Entry<String, Object>> sequence = new ArrayList<>(map.entrySet());
    if (comparator != null) {
      sequence.sort(Entry.comparingByKey(comparator));
    }
    TemplateHashLooper looper = new TemplateHashLooper(sequence);
    ListEnvironment hashEnvironment = new ListEnvironment(context.getEnvironment(), keyIdentifier, valueIdentifier, looperIdentifier, looper);
    processLoop(context, hashEnvironment);
  } catch (RuntimeException e) {
    throw new ProcessException(e.getMessage(), ftl, e);
  }
}

If a comparator has been passed, the list of Map Entry instances is sorted using it. The helpful standard method Entry.comparingByKey is used for this.

This concludes the implementation of the new feature and the (shortened and censored) result of our example changes with sorted asc.

1. file.encoding => UTF-8
2. file.separator => \
3. idea.test.cyclic.buffer.size => 1048576
4. java.class.path => ...
5. java.class.version => 65.0
6. java.home => ...
7. java.io.tmpdir => ...
8. java.library.path => ...
9. java.runtime.name => OpenJDK Runtime Environment
10. java.runtime.version => 21.0.2+13-58

If you want to test the new feature, you can find it on Maven Central.

Leave a Comment