Hamcrest Matcher Generator (Teil 2)

Im vorherigen Beitrag wurde die grobe Struktur eines Generators für Hamcrest Matcher vorgestellt. In diesem Beitrag vervollständigen wir den Entwurf, in dem die losen Enden verknüpft und die tatsächlichen Matcher-Klassen generiert werden.

Da sich in der Zwischenzeit die Implementierung etwas verändert hat, wird die ganze Implementierung des HamcrestMatcherBuilderProcessor besprochen.

Der Generator als Implementierung eines Annotation Processors verarbeitet in seiner process Methode alle von ihm unterstützten Annotationen. Im Fall des Generators ist dies die @Hamcrest Annotation.

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
  return annotations.stream().allMatch(annotation -> processAnnotations(annotation, roundEnv));
}

Die erste Schritt der Generierung geschieht in der processAnnotationsMethode.

private boolean processAnnotations(TypeElement annotation, RoundEnvironment roundEnv) {
  return roundEnv.getElementsAnnotatedWith(annotation).stream()
      .map(TypeElement.class::cast).map(this::extractValueType)
      .filter(Objects::nonNull).map(m -> (TypeElement) processingEnv.getTypeUtils().asElement(m))
      .allMatch(this::handleSourceType);
}

Die tatsächlich annotierten Klassen werden mit roundEnv.getElementsAnnotatedWith bestimmt und die daran annotierte Ziel-Klassen mit extractValueType ausgelesen.

public TypeMirror extractValueType(TypeElement e) {
  return e.getAnnotationMirrors().stream()
      .filter(m -> Hamcrest.class.getName().equals(m.getAnnotationType().toString()))
      .map(m -> m.getAnnotationType().asElement().accept(new HamcrestValueVisitor(), m.getElementValues()))
      .findFirst().orElse(null);
} 

Da die Klasse auch mit anderen Annotationen versehen sein kann, müssen wir auf Hamcrest filtern. Falls keine @Hamcrest Annotation gefunden wird, liefert die Methode null zurück. Das kann nie passieren, weil wir diese Klasse nur wegen ihrer @Hamcrest Annotation verarbeiten.

Die eigentliche Generierung geschieht in der handleSourceType Methode. Hier wird die Ziel-Klasse ausgelesen und die Sourcen der Matcher-Klasse generiert.

private boolean handleSourceType(TypeElement sourceType) {
    try {
      List<ExecutableElement> methods = sourceType.getEnclosedElements().stream()
          .filter(element -> element.getKind() == ElementKind.METHOD)
          .map(ExecutableElement.class::cast)
          .filter(this::filterModifiers).filter(this::filterGetter)
          .collect(toList());
      String sourceName = sourceType.getSimpleName().toString();
      String matcherTypeName = sourceName + "Matchers";
      Name packageName = processingEnv.getElementUtils().getPackageOf(sourceType).getQualifiedName();
      return createMatcherClassFile(methods, sourceName, matcherTypeName, packageName);
    } catch (IOException ex) {
      return false;
    }
  }

Im ersten Schritt werden alle Methoden per Reflections ausgelesen die der Java Beans Getter Konvention für Attribute entsprechen um dann im zweiten Schritt die Matcher-Klasse erzeugt.

Der Name und das Package der Matcher-Klasse wird vom Namen der Ziel-Klasse abgeleitet.

private boolean createMatcherClassFile(List<ExecutableElement> methods, String sourceName,
      String matcherTypeName, Name packageName) throws IOException {
    JavaFileObject builderFile = processingEnv.getFiler().createSourceFile(matcherTypeName);
    if (builderFile.getLastModified() > 0) {
      return false;
    }
    try (PrintWriter out = new PrintWriter(builderFile.openWriter())) {
      String classHeaderTemplate = properties.getProperty("class.header");
      out.println(String.format(classHeaderTemplate, packageName, matcherTypeName, LocalDate.now()));

      methods.forEach(method -> createMatcherMethod(sourceName, out, method, packageName));

      out.println(properties.getProperty("class.footer"));
    }
    return true;
  }

Die Klasse JavaFileObject erzeugt die gewünschte Klasse im korrekten Unterordner, der sich aus dem Package-Namen ergibt. In Verbindung mit dem Maven Compiler Plugin auch im korrekten Maven Target-Verzeichnis.

Für die PersonMatchers Klasse also die Datei target/generated-classes/annotations/de/schegge/ancestors/PersonsMatcher.java.

Der Inhalt der Datei gliedert sich in den Header-, den Footer- und den Methodenbereich. Für alle drei Bereiche stehen einfache Templates bereit, die mit den entsprechenden Werten gefüllt, den gewünschten Source-Code ergeben.

Der Footer-Bereich ist trivial, er besteht nur aus der schließenden Klammer der Klassendefinition. Im Header-Bereich stehen die Package Deklaration, die Import Statements und der Beginn der Klassen Deklaration und ein wenig Javadoc.

class.header=\
/**%n\
 * @author Hamcrest Matcher Generator by Jens Kaiser%n\
 * @version 1.0%n\
 *%n\
 * @see <a href="https://gitlab.com/schegge/hamcrest-matcher-generator">Hamcrest Matcher Generator</a>%n\
 */%n\
package %1$s;%n\
%n\
import static org.hamcrest.Matchers.equalTo;%n\
%n\
import org.hamcrest.FeatureMatcher;%n\
import org.hamcrest.Matcher;%n\
import de.schegge.processor.OptionalMatchers;%n\
%n\
public final class %2$s {

Der Methodenbereich wird durch mehrere Templates für jede gesammelte Methode aus der Ziel-Klasse gefüllt.

private void createMatcherMethod(String sourceName, PrintWriter out, ExecutableElement method, Name packageName) {
  String methodName = method.getSimpleName().toString();
  String attributeName = createArgumentName(methodName);
  DeclaredType declaredType = (DeclaredType) method.getReturnType();
  String variableName = attributeName.substring(0, 1).toLowerCase() + attributeName.substring(1);

  TypeElement typeElement = (TypeElement) declaredType.asElement();
  if (typeElement.toString().equals(Optional.class.getName())) {
    String returnTypeName = createReturnTypeName(declaredType.getTypeArguments().get(0).toString(), packageName);
    String methodTemplate1 = properties.getProperty("class.matcherMethod");
    String methodTemplate2 = properties.getProperty("class.optionalMethod");
    out.printf(methodTemplate1, sourceName, attributeName, declaredType.toString(), variableName, methodName);
    out.printf(methodTemplate2, sourceName, attributeName, returnTypeName, variableName, methodName);
    return;
  }

  String returnTypeName = createReturnTypeName(method.getReturnType().toString(), packageName);
  String methodTemplate1 = properties.getProperty("class.method");
  String methodTemplate2 = properties.getProperty("class.matcherMethod");
  out.printf(methodTemplate1, sourceName, attributeName, returnTypeName, variableName, methodName);
  out.printf(methodTemplate2, sourceName, attributeName, returnTypeName, variableName, methodName);
}

Ein Template fügt eine Methode ein, die mit einem Matcher fur den Ergebnistyp der Methode parametrisiert ist und ein anderes Template erstellt eine spezialisierte Convenience Methode parametrisiert mit dem Ergebnistyp der Ziel-Methode. Diese ruft die vorhergehende Methode mit dem Hamcrest IsEqual.equalTo Matcher auf.

Für Attribute vom Typ Optional gibt es eigene Templates. Da Optional als Methoden-Parameter nicht gerne gesehen sind, gibt es eine Matcher-Methoden mit denen geprüft wird, ob das Optional gesetzt oder leer ist und eine weitere Method zum Prüfen des Inhalts.

Damit ist der Hamcrest Matcher Generator auch schon fertig implementiert und kann zur Generierung eigener Matcher Klassen genutzt werden. Etwas Konfiguration oder der Einbau einer Template Engine sind noch denkbar aber der Inhalt zukünftiger Beiträge.

Für Interessierte ist der Sourcecode des Generators wieder auf GitLab zu finden.