Whitespace Handling in FreshMarker

Bei der Entwicklung des EnumConverterProcessor wurde der Quellcode mit Hilfe der Template-Engine FreeMarker geschrieben. Daher war die Neugierde groß, ob der Quellcode auch mit FreshMarker erfolgreich erzeugt werden kann.

Dabei zeigte sich jedoch, das der Umgang mit Whitespaces in der neuen Template-Engine noch nicht ganz ausgereift war. Genaugenommen produzierte die Template-Engine zu viele Leerzeichen und Zeilenumbrüche.

␣␣<#list ancestors as ancestor, loop>␣␣⏎
␣␣${ancestor.firstname}␣${ancestor.lastname}⏎
␣␣</#list>␣␣␣␣⏎

Das hier dargestellte Template produziert eine Liste von Namen. Dabei solle das Ergebnis nur die Leerzeichen und den Zeilenumbruch aus der zweiten Zeile enthalten. Für die Namen Johann Seemann und Friedrich Magnus Kayser ist das erwartete Ergebnis eine Liste aus zwei Zeilen mit jeweils zwei Zeichen Einrückung.

␣␣Johann␣Seemann⏎
␣␣Friedrich Magnus␣Kayser⏎

Die blau markierten Leerzeichen und Zeilenumbrüche aus den Zeilen, die nur Steueranweisungen enthalten, sollten nicht in den Ergebnis einfließen.

␣␣<#list ancestors as ancestor, loop>␣␣⏎
␣␣${ancestor.firstname}␣${ancestor.lastname}⏎
␣␣</#list>␣␣␣␣⏎

Da FreshMarker diese Behandlung von Whitespace noch nicht beherrschte, wurde die folgende Ausgabe mit fünf Zeilen produziert.

␣␣␣␣⏎
␣␣Johann␣/Seemann/⏎
␣␣␣␣⏎
␣␣Friedrich Magnus␣/Kayser/⏎
␣␣␣␣␣␣⏎ 

Bei einigen Anwendungsfällen ist dies sicher zu verschmerzen, aber in manchen Szenarien sind zusätzliche Zeilenumbrüche nicht akzeptabel.

Das Problem kann gelöst werden, in dem alle Whitespaces entfernt werden, die in am Anfang und am Ende einer Kommandozeile stehen. Leerzeichen innerhalb einer Kommandozeile gehören zu erwarteten Ausgabe und dürfen nicht gelöscht werden. Die erste Aufgabe ist also das Identifizieren einer einzelnen Zeile. Dies klingt im ersten Moment sehr einfach, weil eine Zeile mit einem Zeilenumbruch endet.

1: WHITESPACE OPEN_DIRECTIVE LIST BLANK IDENTIFIER AS IDENTIFIER COMMA IDENTIFIER CLOSE_TAG WHITESPACE 
2: INTERPOLATE IDENTIFIER DOT IDENTIFIER CLOSE_BRACE WHITESPACE INTERPOLATE IDENTIFIER DOT IDENTIFIER CLOSE_BRACE WHITESPACE
3: END_DIRECTIVE WHITESPACE  EOF

Im obigen Block sind die Token aufgeführt, die der FreshMarker Parser für das Eingangsbeispiel entdeckt. Der Parser wurde mit JavaCC 21 erzeugt und nutzt dessen leicht abgewandelte Freemarker Grammatik. Die Liste der Token wurde so aufgeteilt, dass die Token den Zeilen zugeordnet werden, in denen sie beginnen. Blau sind Token dargestellt, die zu FreshMarker Befehlen gehören, weiß sind Token, die auf Inhalte hinweisen, die nicht FreshMarker Befehlen sind und grau sind Whitespaces die Zeilenumbrüche enthalten und genauer betrachtet werden müssen.

Bei dem INTERPOLATE Token handelt es sich um das $ Zeichen zu Beginn einer Variableninterpolation. An dieser Stelle wird also Inhalt ausgegeben und umgebene Leerzeichen sollten nicht wegfallen. Daher sollte aus dieser Zeilen nichts entfernt werden. Die WHITESPACE Token, die Zeilenumbrüche enthalten, müssen genauer betrachtet werden, weil diese mehr als nur einen Zeilenumbruch beinhalten können. Der Parser sammelt alle zusammenhängenden Whitespaces in einem Token zusammen. Im hier betrachteten Beispiel enthält des erste WHITESPACE Token also die Zeichen ␣␣⏎␣␣. Die ersten drei Zeichen gehören zur ersten Zeile und die letzten beiden Zeichen zur zweiten Zeile. Um eine vereinfachte Ausgangslage für das Löschen unnötiger Whitespaces zu schaffen, sollte also erst einmal die Liste der Token normalisiert werden.

1: WHITESPACE OPEN_DIRECTIVE LIST BLANK IDENTIFIER AS IDENTIFIER COMMA IDENTIFIER CLOSE_TAG WHITESPACE 
2: WHITESPACE INTERPOLATE IDENTIFIER DOT IDENTIFIER CLOSE_BRACE WHITESPACE INTERPOLATE IDENTIFIER DOT IDENTIFIER CLOSE_BRACE WHITESPACE
3: WHITESPACE END_DIRECTIVE WHITESPACE EOF

Das WHITESPACE am Ende der ersten Zeile wurde in zwei Whitespaces aufgesplittet. Das WHITESPACE am Ende der ersten Zeile, das entfernt werden kann und das WHITESPACE, das in der Ausgabe erhalten bleiben muss. Auch das WHITESPACE am Ende der zweiten Zeile wurde in zwei Whitespaces aufgesplittet. Das WHITESPACE am Ende der Zeile muss erhalten bleiben. Das WHITESPACE in der darauffolgenden Zeile kann gelöscht werden, weil diese Zeile ansonsten nur Token für FreshMarker Befehle enthält.

Eine erste Implementierung zur Normalisierung der Whitespaces ist hier dargestellt.

private void cleanUpWhitespaces(Root root) {
  root.getAllTokens(false).stream().filter(t -> t.getType() == TokenType.WHITESPACE)
      .forEach(this::normalizeWhitespaces);
  List<Token> currentLine = new ArrayList<>();
  root.getAllTokens(false).forEach(token -> cleanUpLine(currentLine, token));
}

private void normalizeWhitespaces(Token token) {
  String image = token.getImage();
  int index = image.indexOf("\n");
  if (index == -1 || index == image.length() - 1) {
    return;
  }
  int beginOffset = token.getBeginOffset();
  int endOffset = token.getEndOffset();
  Node parent = token.getParent();
  int tokenIndex = parent.indexOf(token);
  parent.removeChild(token);
  int start = beginOffset;
  for (int i = 0; i < image.length(); i++) {
    char c = image.charAt(i);
    if (c == '\n') {
      parent.addChild(tokenIndex, newToken(token, start, beginOffset + i + 1));
      tokenIndex++;
      start = beginOffset + i + 1;
    }
  }
  if (start <= endOffset) {
    parent.addChild(tokenIndex, newToken(token, start, endOffset));
  }
}

Ausgehend vom Root Element werden in der Methode cleanUpWhitespaces alle Tokens in einer Liste gesammelt. Danach werden in der Methode normalizeWhitespaces die Whitespace Token die mindestens einen Zeilenumbruch enthalten in neue Tokens aufgesplittet und das bestehende Token durch die neuen ersetzt.

Nachdem die Whitespaces normalisiert wurden können nun die überflüssigen Whitespaces einfach entfernt werden.

private void cleanUpLine(List<Token> currentLine, Token token) {
  if (token.getType() != TokenType.WHITESPACE || !token.getImage().endsWith("\n")) {
    currentLine.add(token);
    return;
  }
  if (currentLine.isEmpty()) {
    return;
  }
  if (Set.of(TokenType.INTERPOLATE, TokenType.PRINTABLE_CHARS).contains(currentLine.get(0).getType())) {
    currentLine.clear();
    return;
  }
  if (currentLine.stream().skip(1).anyMatch(
      t -> Set.of(TokenType.WHITESPACE, TokenType.INTERPOLATE, TokenType.PRINTABLE_CHARS)
          .contains(t.getTokenType()))) {
    currentLine.clear();
    return;
  }
  Token firstToken = currentLine.get(0);
  if (firstToken.getType() == TokenType.WHITESPACE) {
    firstToken.getParent().removeChild(firstToken);
  }
  token.getParent().removeChild(token);
  currentLine.clear();
}

Jedes Token wird an die cleanUpLine Methode übergeben. Die Token für eine Zeile werden in einer entsprechenden Liste gesammelt und bei Whitespaces, die am Ende einen Zeilenumbruch enthalten und damit das Zeilenende signalisieren, wird die Zeile auf überflüssige Leerzeichen überprüft.

Enthält eine Zeile WHITESPACE, INTERPOLATE, PRINTABLE_CHARS ab der zweiten Position, dann kann nichts gelöscht werden und die Bearbeitung ist beendet. Ansonsten wird das WHITESPACE am Ende der Zeile und ein mögliches WHITESPACE am Anfang der Zeile gelöscht.

Damit ist die Implementierung für die bessere Behandlung von Whitespaces in Freshmarker Templates auch schon beendet und kann in einer der nächsten Implementierungen des EnumConverterProcessor genutzt werden.

Wer selbst einmal mit FreshMarker experimentieren möchte, findet die Bibliothek auf Maven Central.

<dependency>
  <groupId>de.schegge</groupId>
  <artifactId>freshmarker</artifactId>
  <version>0.2.1</version>
</dependency>

1 thought on “Whitespace Handling in FreshMarker”

Leave a Comment