Generate typed Java ViewModels and discover declarative registry defaults

This commit is contained in:
2026-11-13 20:06:26 +01:00
parent adf432b98b
commit 8be0aec287
36 changed files with 1123 additions and 17 deletions
+2
View File
@@ -6,6 +6,8 @@
<dependencies>
<dependency><groupId>de.neitzel.fxdesigner</groupId><artifactId>designer-contracts</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><scope>test</scope></dependency>
<dependency><groupId>de.neitzel.lib</groupId><artifactId>fx</artifactId><version>1.0-SNAPSHOT</version><scope>test</scope></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.42</version><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><configuration><proc>none</proc></configuration></plugin></plugins></build>
</project>
@@ -11,10 +11,23 @@ import javax.tools.*;
/** Explicitly selected Models only; no application classloading or code execution. */
@SupportedAnnotationTypes("*")
@SupportedOptions("designer.models")
@SupportedOptions({
"designer.models",
"designer.viewModels",
"designer.viewModelModels",
"designer.generatedPackage",
"designer.viewModelDefaults"
})
public final class ModelMetadataProcessor extends AbstractProcessor {
private final Set<String> emitted = new HashSet<>();
private final Set<Element> sources = new HashSet<>();
private final Map<String, ModelMetadata> metadata = new TreeMap<>();
private final Map<String, TypeElement> modelTypes = new TreeMap<>();
private boolean delayed;
private boolean generated;
private boolean typed() {
return "true".equals(processingEnv.getOptions().get("designer.viewModels"));
}
@Override
public SourceVersion getSupportedSourceVersion() {
@@ -24,9 +37,43 @@ public final class ModelMetadataProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment round) {
if (round.processingOver()) return false;
sources.addAll(round.getRootElements());
String selection = processingEnv.getOptions().getOrDefault("designer.models", "");
if (selection == null || selection.isBlank()) return false;
if (typed()) {
var selectedTypes =
Arrays.stream(selection.split(","))
.map(String::trim)
.map(processingEnv.getElementUtils()::getTypeElement)
.toList();
if (selectedTypes.stream().allMatch(java.util.Objects::nonNull)
&& selectedTypes.stream()
.noneMatch(t -> com.sun.source.util.Trees.instance(processingEnv).getPath(t) != null))
return false;
}
// Give accessor-generating processors (including Lombok) a completed source round.
if (typed() && !delayed) {
delayed = true;
try (var out =
processingEnv
.getFiler()
.createSourceFile(
processingEnv
.getOptions()
.getOrDefault("designer.generatedPackage", "generated.viewmodels")
+ ".ProcessorRound")
.openWriter()) {
out.write(
"package "
+ processingEnv
.getOptions()
.getOrDefault("designer.generatedPackage", "generated.viewmodels")
+ "; final class ProcessorRound {} ");
} catch (Exception e) {
throw new IllegalStateException(e);
}
return false;
}
if (generated) return false;
for (String name : selection.split(",")) {
var type = processingEnv.getElementUtils().getTypeElement(name.trim());
if (type == null) {
@@ -43,6 +90,16 @@ public final class ModelMetadataProcessor extends AbstractProcessor {
Diagnostic.Kind.ERROR, "Cannot generate Model metadata: " + e.getMessage(), type);
}
}
if (typed()) {
generated = true;
try {
new ViewModelGenerator(processingEnv, metadata, modelTypes).generate();
} catch (Exception e) {
processingEnv
.getMessager()
.printMessage(Diagnostic.Kind.ERROR, "Cannot generate ViewModels: " + e.getMessage());
}
}
return false;
}
@@ -98,8 +155,10 @@ public final class ModelMetadataProcessor extends AbstractProcessor {
while (source.getEnclosingElement() instanceof TypeElement)
source = source.getEnclosingElement();
// Test compilation and dependent modules must not duplicate metadata for classpath Models.
if (!sources.contains(source) || depth > 4 || name.startsWith("java.") || !emitted.add(name))
return;
if (com.sun.source.util.Trees.instance(processingEnv).getPath(source) == null
|| depth > (typed() ? 256 : 4)
|| name.startsWith("java.")
|| !emitted.add(name)) return;
var readers = new TreeMap<String, ExecutableElement>();
var setters = new ArrayList<ExecutableElement>();
for (var member : processingEnv.getElementUtils().getAllMembers(owner)) {
@@ -148,12 +207,19 @@ public final class ModelMetadataProcessor extends AbstractProcessor {
if (value instanceof DeclaredType d && !collection(value))
nested.add((TypeElement) d.asElement());
}
var description = new ModelMetadata(name, properties);
metadata.put(name, description);
modelTypes.put(name, owner);
if (typed()) {
TypeMirror parent = owner.getSuperclass();
if (parent instanceof DeclaredType d) emit((TypeElement) d.asElement(), depth + 1);
}
var resource =
processingEnv
.getFiler()
.createResource(StandardLocation.CLASS_OUTPUT, "", ModelMetadata.resource(name), owner);
try (var out = resource.openOutputStream()) {
out.write(new ModelMetadata(name, properties).encode());
out.write(description.encode());
}
for (var n : nested) emit(n, depth + 1);
}
@@ -0,0 +1,318 @@
package de.neitzel.fxdesigner.processor;
import de.neitzel.fxdesigner.protocol.ModelMetadata;
import java.util.*;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.*;
import javax.lang.model.type.DeclaredType;
import javax.tools.StandardLocation;
/** Generates accessors from the same Model description packaged for discovery. */
final class ViewModelGenerator {
private final ProcessingEnvironment env;
private final Map<String, ModelMetadata> models;
private final Map<String, TypeElement> types;
private final String pkg;
private final Map<String, String> names = new TreeMap<>();
ViewModelGenerator(
ProcessingEnvironment env,
Map<String, ModelMetadata> models,
Map<String, TypeElement> types) {
this.env = env;
this.models = models;
this.types = types;
pkg = env.getOptions().getOrDefault("designer.generatedPackage", "generated.viewmodels");
if (!javax.lang.model.SourceVersion.isName(pkg))
throw new IllegalArgumentException("Invalid generated package: " + pkg);
}
private boolean assignable(String name, String parent) {
var a = env.getElementUtils().getTypeElement(name.replace('$', '.'));
var b = env.getElementUtils().getTypeElement(parent);
return a != null
&& b != null
&& env.getTypeUtils()
.isAssignable(
env.getTypeUtils().erasure(a.asType()), env.getTypeUtils().erasure(b.asType()));
}
private boolean simple(String name) {
var t = env.getElementUtils().getTypeElement(name.replace('$', '.'));
return t == null
|| t.getKind() == ElementKind.ENUM
|| name.startsWith("java.")
|| assignable(name, "java.lang.Number")
|| assignable(name, "java.util.Date")
|| assignable(name, "java.util.Calendar")
|| assignable(name, "java.time.temporal.TemporalAccessor")
|| assignable(name, "java.time.temporal.TemporalAmount");
}
private String javaType(String binary) {
if (!binary.startsWith("[")) return binary.replace('$', '.');
String c = binary.substring(1);
return (c.startsWith("L")
? c.substring(1, c.length() - 1).replace('$', '.')
: switch (c) {
case "I" -> "int";
case "J" -> "long";
case "Z" -> "boolean";
case "B" -> "byte";
case "S" -> "short";
case "C" -> "char";
case "F" -> "float";
case "D" -> "double";
default -> javaType(c);
})
+ "[]";
}
private String value(ModelMetadata.Property p) {
if (names.containsKey(p.type())) return names.get(p.type());
if (assignable(p.type(), "java.util.Collection") && !p.elementType().isEmpty())
return "javafx.collections.ObservableList<" + javaType(p.elementType()) + ">";
return javaType(p.type());
}
private String specialized(String type) {
return switch (type) {
case "java.lang.String" -> "String";
case "int", "java.lang.Integer" -> "Integer";
case "long", "java.lang.Long" -> "Long";
case "float", "java.lang.Float" -> "Float";
case "double", "java.lang.Double" -> "Double";
case "boolean", "java.lang.Boolean" -> "Boolean";
default -> "";
};
}
private String boxed(String type) {
return switch (type) {
case "byte" -> "java.lang.Byte";
case "short" -> "java.lang.Short";
case "char" -> "java.lang.Character";
default -> type;
};
}
private String property(ModelMetadata.Property p) {
String scalar = specialized(p.type());
return scalar.isEmpty()
? "javafx.beans.property.Property<" + boxed(value(p)) + ">"
: "javafx.beans.property." + scalar + "Property";
}
private String selectedModel(String name) {
var type = env.getElementUtils().getTypeElement(name.replace('$', '.'));
if (type == null)
throw new IllegalArgumentException("Selected ViewModel Model not found: " + name);
return env.getElementUtils().getBinaryName(type).toString();
}
void generate() throws Exception {
var required = new HashSet<String>();
var queue = new ArrayDeque<String>();
String selected =
env.getOptions()
.getOrDefault("designer.viewModelModels", env.getOptions().get("designer.models"));
for (String name : selected.split(",", -1)) {
String binary = selectedModel(name.trim());
if (!models.containsKey(binary))
throw new IllegalArgumentException(
"Selected ViewModel Model has no source metadata: " + name);
queue.add(binary);
}
while (!queue.isEmpty()) {
String name = queue.remove();
if (!models.containsKey(name) || simple(name) || !required.add(name)) continue;
for (var p : models.get(name).properties())
if (models.containsKey(p.type())
&& !simple(p.type())
&& !models.get(p.type()).properties().isEmpty()) queue.add(p.type());
var type = types.get(name);
if (type.getSuperclass() instanceof DeclaredType d)
queue.add(env.getElementUtils().getBinaryName((TypeElement) d.asElement()).toString());
}
for (var m : models.values())
if (required.contains(m.model())) {
var type = types.get(m.model());
var methods =
env.getElementUtils().getAllMembers(type).stream()
.filter(
e ->
e instanceof ExecutableElement
&& e.getModifiers().contains(Modifier.PUBLIC))
.map(e -> e.getSimpleName().toString())
.collect(java.util.stream.Collectors.toSet());
if (methods.contains("getLatestMessage") && methods.contains("setLatestMessage"))
throw new IllegalArgumentException(
m.model() + ".latestMessage conflicts with ViewModel.getLatestMessage");
if (!type.getModifiers().contains(Modifier.PUBLIC))
throw new IllegalArgumentException("Generated Model must be public: " + m.model());
if (!type.getTypeParameters().isEmpty())
throw new IllegalArgumentException(
"Generic Model needs a concrete declaration: " + m.model());
for (TypeElement c = type; c != null; ) {
if (env.getElementUtils().getTypeElement(c.getQualifiedName() + "BeanInfo") != null)
throw new IllegalArgumentException(
"Custom BeanInfo requires explicit mapping: " + m.model());
c = c.getSuperclass() instanceof DeclaredType d ? (TypeElement) d.asElement() : null;
}
String name = pkg + "." + type.getSimpleName() + "ViewModel";
if (names.containsValue(name))
throw new IllegalArgumentException("Generated class name collision: " + name);
names.put(m.model(), name);
}
var defaults = new TreeMap<String, String>();
String settings = env.getOptions().getOrDefault("designer.viewModelDefaults", "");
if (settings != null && !settings.isBlank())
for (String item : settings.split(",")) {
var pair = item.split("=", -1);
if (pair.length != 2)
throw new IllegalArgumentException("Invalid ViewModel default: " + item);
pair[0] = selectedModel(pair[0].trim());
pair[1] = pair[1].trim();
if (!names.containsKey(pair[0])
|| !javax.lang.model.SourceVersion.isName(pair[1])
|| defaults.put(pair[0], pair[1]) != null)
throw new IllegalArgumentException("Invalid/conflicting ViewModel default: " + item);
}
var runtime = env.getElementUtils().getTypeElement("de.neitzel.fx.view.ViewModel");
if (runtime == null)
throw new IllegalArgumentException(
"NeitzelLib FX with generated ViewModel support is required");
var reserved = new HashSet<String>();
for (var member : env.getElementUtils().getAllMembers(runtime))
if (member instanceof ExecutableElement m) reserved.add(m.getSimpleName().toString());
for (var entry : names.entrySet()) {
var model = models.get(entry.getKey());
var type = types.get(entry.getKey());
String parentModel =
type.getSuperclass() instanceof DeclaredType d
? env.getElementUtils().getBinaryName((TypeElement) d.asElement()).toString()
: "";
String parent = names.getOrDefault(parentModel, "de.neitzel.fx.view.ViewModel");
var inherited = new HashMap<String, ModelMetadata.Property>();
if (names.containsKey(parentModel))
for (var p : models.get(parentModel).properties()) inherited.put(p.name(), p);
var source =
new StringBuilder(
"package "
+ pkg
+ ";\n@SuppressWarnings({\"unchecked\",\"rawtypes\"})\npublic class "
+ entry.getValue().substring(pkg.length() + 1)
+ " extends "
+ parent
+ " {\n");
source
.append(" public ")
.append(entry.getValue().substring(pkg.length() + 1))
.append('(')
.append(javaType(model.model()))
.append(" model) { super(model); }\n");
for (var p : model.properties()) {
if (!simple(p.type()) && !names.containsKey(p.type())) {
var child = env.getElementUtils().getTypeElement(p.type().replace('$', '.'));
var members =
env.getElementUtils().getAllMembers(child).stream()
.filter(
e ->
e instanceof ExecutableElement
&& e.getModifiers().contains(Modifier.PUBLIC))
.map(e -> e.getSimpleName().toString())
.collect(java.util.stream.Collectors.toSet());
if (members.stream()
.anyMatch(n -> n.startsWith("get") && members.contains("set" + n.substring(3))))
throw new IllegalArgumentException(
"Nested Model requires an available generated mapping: "
+ model.model()
+ "."
+ p.name());
}
String cap = Character.toUpperCase(p.name().charAt(0)) + p.name().substring(1);
for (String n : List.of(p.name() + "Property", "get" + cap, "set" + cap))
if (reserved.contains(n))
throw new IllegalArgumentException(
model.model() + "." + p.name() + " conflicts with ViewModel." + n);
if (inherited.containsKey(p.name())) {
var old = inherited.get(p.name());
if (!property(old).equals(property(p)) || !value(old).equals(value(p)))
throw new IllegalArgumentException(
"Incompatible writable Attribute narrowing: " + model.model() + "." + p.name());
continue;
}
source
.append(" public ")
.append(property(p))
.append(' ')
.append(p.name())
.append("Property() { return (")
.append(property(p))
.append(") (javafx.beans.property.Property<?>) storedProperty(\"")
.append(p.name())
.append("\"); }\n");
source
.append(" public ")
.append(value(p))
.append(" get")
.append(cap)
.append("() { return ")
.append(p.name())
.append("Property().")
.append(specialized(p.type()).isEmpty() ? "getValue()" : "get()")
.append("; }\n");
source
.append(" public void set")
.append(cap)
.append('(')
.append(value(p))
.append(" value) { ")
.append(p.name())
.append("Property().setValue(value); }\n");
}
source.append("}\n");
try (var out = env.getFiler().createSourceFile(entry.getValue(), type).openWriter()) {
out.write(source.toString());
}
}
if (names.isEmpty()) return;
String registry = pkg + ".GeneratedViewModelRegistry";
var source = new StringBuilder("package " + pkg + ";\n");
for (var entry : names.entrySet())
source
.append("@de.neitzel.fx.view.ViewModelRegistration(model=")
.append(javaType(entry.getKey()))
.append(".class,generated=")
.append(entry.getValue())
.append(".class,defaultType=")
.append(defaults.getOrDefault(entry.getKey(), entry.getValue()))
.append(".class)\n");
source.append(
"public final class GeneratedViewModelRegistry implements"
+ " de.neitzel.fx.view.ViewModelRegistry {\n");
int index = 0;
for (var e : defaults.entrySet())
source
.append(" private Class<? extends ")
.append(names.get(e.getKey()))
.append("> check")
.append(index++)
.append("() { return ")
.append(e.getValue())
.append(".class; }\n");
source.append("}\n");
try (var out = env.getFiler().createSourceFile(registry).openWriter()) {
out.write(source.toString());
}
try (var out =
env.getFiler()
.createResource(
StandardLocation.CLASS_OUTPUT,
"",
"META-INF/services/de.neitzel.fx.view.ViewModelRegistry")
.openWriter()) {
out.write(registry + "\n");
}
}
}
@@ -0,0 +1,197 @@
package de.neitzel.fxdesigner.processor;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.*;
import java.util.*;
import javax.annotation.processing.Processor;
import javax.tools.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ViewModelGenerationTest {
@TempDir Path root;
String diagnostics;
boolean compile(
String source, String custom, boolean lombok, boolean reverse, String... extraOptions)
throws Exception {
Path src = root.resolve("src"), out = root.resolve("out"), gen = root.resolve("generated");
Files.createDirectories(src);
Files.createDirectories(out);
Files.createDirectories(gen);
Files.writeString(src.resolve("Person.java"), source);
var inputs = new ArrayList<java.io.File>();
inputs.add(src.resolve("Person.java").toFile());
if (custom != null) {
Files.writeString(src.resolve("Custom.java"), custom);
inputs.add(src.resolve("Custom.java").toFile());
}
try (var paths = Files.list(src)) {
inputs.clear();
inputs.addAll(paths.filter(p -> p.toString().endsWith(".java")).map(Path::toFile).toList());
}
var compiler = ToolProvider.getSystemJavaCompiler();
var messages = new DiagnosticCollector<JavaFileObject>();
try (var files = compiler.getStandardFileManager(null, null, null)) {
var options =
new ArrayList<>(
List.of(
"-classpath",
System.getProperty("java.class.path"),
"-d",
out.toString(),
"-s",
gen.toString(),
"-Adesigner.models=sample.Person",
"-Adesigner.viewModels=true",
"-Adesigner.generatedPackage=sample.generated"));
options.addAll(List.of(extraOptions));
if (custom != null) options.add("-Adesigner.viewModelDefaults=sample.Person=sample.Custom");
var task =
compiler.getTask(
null, files, messages, options, null, files.getJavaFileObjectsFromFiles(inputs));
var processors = new ArrayList<Processor>();
processors.add(new ModelMetadataProcessor());
if (lombok)
processors.add(
(Processor)
Class.forName("lombok.launch.AnnotationProcessorHider$AnnotationProcessor")
.getConstructor()
.newInstance());
if (reverse) Collections.reverse(processors);
task.setProcessors(processors);
boolean success = task.call();
diagnostics = messages.getDiagnostics().toString();
return success;
}
}
@Test
void nestedSelectionsAreNormalizedAndUnknownSelectionsFail() throws Exception {
String source =
"package sample; public class Person { public static class Child { public String"
+ " getName(){return null;} public void setName(String n){} } }";
assertTrue(
compile(
source,
null,
false,
false,
"-Adesigner.models=sample.Person.Child",
"-Adesigner.viewModelModels=sample.Person.Child"),
diagnostics);
assertTrue(Files.exists(root.resolve("generated/sample/generated/ChildViewModel.java")));
assertFalse(compile(source, null, false, false, "-Adesigner.viewModelModels=sample.Missing"));
assertTrue(diagnostics.contains("Selected ViewModel Model not found"), diagnostics);
}
@Test
void cleanCompilationSupportsHandwrittenSubclassAndStoredAccessors() throws Exception {
assertTrue(
compile(
"package sample; public class Person { public String getName(){return null;} public"
+ " void setName(String n){} public Person getChild(){return null;} public void"
+ " setChild(Person p){} }",
"package sample; public class Custom extends sample.generated.PersonViewModel { public"
+ " Custom(Person p){super(p);} public String greeting(){return getName();} }",
false,
false));
String generated =
Files.readString(root.resolve("generated/sample/generated/PersonViewModel.java"));
assertTrue(generated.contains("storedProperty(\"name\")"));
assertTrue(generated.contains("Property<sample.generated.PersonViewModel>"));
assertTrue(
Files.exists(root.resolve("out/META-INF/services/de.neitzel.fx.view.ViewModelRegistry")));
}
@Test
void lombokAccessorsWorkInBothProcessorOrders() throws Exception {
for (boolean reverse : List.of(false, true)) {
Path previous = root;
root = Files.createDirectory(previous.resolve("order" + reverse));
assertTrue(
compile(
"package sample; @lombok.Getter @lombok.Setter public class Person { private String"
+ " name; }",
null,
true,
reverse));
assertTrue(
Files.readString(root.resolve("generated/sample/generated/PersonViewModel.java"))
.contains("nameProperty"));
root = previous;
}
}
@Test
void reservedNamesAndInvalidCustomDefaultsFailCompilation() throws Exception {
assertFalse(
compile(
"package sample; public class Person { public String getLatestMessage(){return null;}"
+ " public void setLatestMessage(String s){} }",
null,
false,
false));
assertFalse(
compile(
"package sample; public class Person { public String getName(){return null;} public"
+ " void setName(String s){} }",
"package sample; public class Custom extends de.neitzel.fx.view.ViewModel { public"
+ " Custom(Person p){super(p);} }",
false,
false));
}
@Test
void removedAttributesBreakCustomCodeWithoutRewritingIt() throws Exception {
String custom =
"package sample; public class Custom extends sample.generated.PersonViewModel { public"
+ " Custom(Person p){super(p);} public String title(){return getName();} }";
assertTrue(
compile(
"package sample; public class Person {public String getName(){return null;} public void"
+ " setName(String v){} }",
custom,
false,
false));
for (String dir : List.of("out", "generated"))
try (var paths = Files.walk(root.resolve(dir))) {
for (var p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);
}
assertFalse(
compile(
"package sample; public class Person {public String getTitle(){return null;} public"
+ " void setTitle(String v){} }",
custom,
false,
false));
assertEquals(custom, Files.readString(root.resolve("src/Custom.java")));
}
@Test
void writableNarrowingAndCustomBeanInfoAreDiagnosed() throws Exception {
Files.createDirectories(root.resolve("src"));
Files.writeString(
root.resolve("src/Parent.java"),
"package sample; public class Parent {public Object getValue(){return null;} public void"
+ " setValue(Object v){} }");
assertFalse(
compile(
"package sample; public class Person extends Parent {public String getValue(){return"
+ " null;} public void setValue(String v){} }",
null,
false,
false));
assertTrue(diagnostics.contains("Incompatible writable Attribute narrowing"), diagnostics);
assertFalse(
compile(
"package sample; public class Person { public String getName(){return null;} public"
+ " void setName(String n){} } class PersonBeanInfo extends"
+ " java.beans.SimpleBeanInfo {}",
null,
false,
false));
assertTrue(diagnostics.contains("Custom BeanInfo"), diagnostics);
}
}