Generate versioned Model metadata and consume it in shared binding discovery

This commit is contained in:
2026-11-13 19:08:30 +01:00
parent 9bc4569407
commit adf432b98b
20 changed files with 700 additions and 15 deletions
@@ -0,0 +1,160 @@
package de.neitzel.fxdesigner.processor;
import de.neitzel.fxdesigner.protocol.ModelMetadata;
import java.beans.Introspector;
import java.util.*;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.*;
import javax.tools.*;
/** Explicitly selected Models only; no application classloading or code execution. */
@SupportedAnnotationTypes("*")
@SupportedOptions("designer.models")
public final class ModelMetadataProcessor extends AbstractProcessor {
private final Set<String> emitted = new HashSet<>();
private final Set<Element> sources = new HashSet<>();
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
@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;
for (String name : selection.split(",")) {
var type = processingEnv.getElementUtils().getTypeElement(name.trim());
if (type == null) {
processingEnv
.getMessager()
.printMessage(Diagnostic.Kind.ERROR, "Selected Model not found: " + name);
} else
try {
emit(type, 0);
} catch (Exception e) {
processingEnv
.getMessager()
.printMessage(
Diagnostic.Kind.ERROR, "Cannot generate Model metadata: " + e.getMessage(), type);
}
}
return false;
}
private String binary(TypeMirror type) {
var types = processingEnv.getTypeUtils();
type = types.erasure(type);
if (type instanceof ArrayType a) {
String c = binary(a.getComponentType());
return "["
+ switch (c) {
case "boolean" -> "Z";
case "byte" -> "B";
case "short" -> "S";
case "int" -> "I";
case "long" -> "J";
case "char" -> "C";
case "float" -> "F";
case "double" -> "D";
default -> c.startsWith("[") ? c : "L" + c + ";";
};
}
return type instanceof DeclaredType d
? processingEnv.getElementUtils().getBinaryName((TypeElement) d.asElement()).toString()
: type.toString();
}
private ExecutableType signature(TypeElement owner, ExecutableElement method) {
return (ExecutableType)
processingEnv.getTypeUtils().asMemberOf((DeclaredType) owner.asType(), method);
}
private boolean collection(TypeMirror type) {
var types = processingEnv.getTypeUtils();
return types.isAssignable(
types.erasure(type),
types.erasure(
processingEnv.getElementUtils().getTypeElement("java.util.Collection").asType()));
}
private String element(TypeMirror type) {
if (!(type instanceof DeclaredType d) || d.getTypeArguments().size() != 1) return "";
TypeMirror value = d.getTypeArguments().getFirst();
if (value instanceof WildcardType w)
value = w.getSuperBound() == null ? w.getExtendsBound() : null;
return value instanceof DeclaredType declared && declared.getTypeArguments().isEmpty()
? binary(value)
: "";
}
private void emit(TypeElement owner, int depth) throws Exception {
String name = processingEnv.getElementUtils().getBinaryName(owner).toString();
Element source = owner;
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;
var readers = new TreeMap<String, ExecutableElement>();
var setters = new ArrayList<ExecutableElement>();
for (var member : processingEnv.getElementUtils().getAllMembers(owner)) {
if (!(member instanceof ExecutableElement m)
|| m.getKind() != ElementKind.METHOD
|| !m.getModifiers().contains(Modifier.PUBLIC)
|| m.getModifiers().contains(Modifier.STATIC)) continue;
String n = m.getSimpleName().toString();
var sig = signature(owner, m);
if (n.startsWith("set")
&& n.length() > 3
&& sig.getParameterTypes().size() == 1
&& sig.getReturnType().getKind() == TypeKind.VOID) setters.add(m);
if (!sig.getParameterTypes().isEmpty() || sig.getReturnType().getKind() == TypeKind.VOID)
continue;
boolean bool =
n.startsWith("is") && n.length() > 2 && sig.getReturnType().getKind() == TypeKind.BOOLEAN;
if (!bool && !(n.startsWith("get") && n.length() > 3)) continue;
String property = Introspector.decapitalize(n.substring(bool ? 2 : 3));
if (bool || !readers.containsKey(property)) readers.put(property, m);
}
var properties = new ArrayList<ModelMetadata.Property>();
var nested = new ArrayList<TypeElement>();
for (var entry : readers.entrySet()) {
String property = entry.getKey();
if (property.equals("class") || property.equals("latestMessage")) continue;
var reader = entry.getValue();
TypeMirror value = signature(owner, reader).getReturnType();
var matching =
setters.stream()
.filter(
m ->
Introspector.decapitalize(m.getSimpleName().toString().substring(3))
.equals(property)
&& binary(signature(owner, m).getParameterTypes().getFirst())
.equals(binary(value)))
.toList();
if (matching.size() != 1) continue;
properties.add(
new ModelMetadata.Property(
property,
binary(value),
collection(value) ? element(value) : "",
reader.getSimpleName().toString(),
matching.getFirst().getSimpleName().toString()));
if (value instanceof DeclaredType d && !collection(value))
nested.add((TypeElement) d.asElement());
}
var resource =
processingEnv
.getFiler()
.createResource(StandardLocation.CLASS_OUTPUT, "", ModelMetadata.resource(name), owner);
try (var out = resource.openOutputStream()) {
out.write(new ModelMetadata(name, properties).encode());
}
for (var n : nested) emit(n, depth + 1);
}
}
@@ -0,0 +1 @@
de.neitzel.fxdesigner.processor.ModelMetadataProcessor
@@ -0,0 +1,120 @@
package de.neitzel.fxdesigner.processor;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.protocol.ModelMetadata;
import java.nio.file.*;
import java.util.*;
import javax.tools.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ModelMetadataProcessorTest {
@TempDir Path root;
private boolean compile(String source, String selected, Path output) throws Exception {
Files.createDirectories(output);
Path file = root.resolve("Person.java");
Files.writeString(file, source);
var compiler = ToolProvider.getSystemJavaCompiler();
try (var files = compiler.getStandardFileManager(null, null, null)) {
var task =
compiler.getTask(
null,
files,
null,
List.of("-d", output.toString(), "-Adesigner.models=" + selected),
null,
files.getJavaFileObjects(file.toFile()));
task.setProcessors(List.of(new ModelMetadataProcessor()));
return task.call();
}
}
private String source(String property) {
return "package example; class Base { public int getRank(){throw new Error();} public void"
+ " setRank(int n){} } public class Person extends Base { static { if(true) throw"
+ " new Error(); } public Person(){throw new Error();} public String get"
+ property
+ "(){throw new Error();} public void set"
+ property
+ "(String v){} public boolean isReady(){return true;} public boolean getReady(){return"
+ " false;} public void setReady(boolean v){} public String getReadOnly(){return null;}"
+ " public java.util.List<? extends Person> getChildren(){return null;} public void"
+ " setChildren(java.util.List<? extends Person> v){} public Person getHome(){return null;}"
+ " public void setHome(Person v){} }";
}
@Test
void emitsDeterministicInheritedAndCollectionSignaturesWithoutExecutingCode() throws Exception {
Path a = root.resolve("a"), b = root.resolve("b");
assertTrue(compile(source("Name"), "example.Person", a));
assertTrue(compile(source("Name"), "example.Person", b));
String resource = ModelMetadata.resource("example.Person");
assertArrayEquals(
Files.readAllBytes(a.resolve(resource)), Files.readAllBytes(b.resolve(resource)));
try (var in = Files.newInputStream(a.resolve(resource))) {
var metadata = ModelMetadata.read(in);
assertEquals(
List.of("children", "home", "name", "rank", "ready"),
metadata.properties().stream().map(ModelMetadata.Property::name).toList());
assertEquals("example.Person", metadata.properties().getFirst().elementType());
assertEquals("isReady", metadata.properties().getLast().reader());
}
}
@Test
void freshBuildRemovesOldPropertiesAndDeselectedModels() throws Exception {
Path output = root.resolve("out");
assertTrue(compile(source("Name"), "example.Person", output));
// The designer uses clean isolated builds, not incremental processor output directories.
try (var paths = Files.walk(output)) {
for (var p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);
}
assertTrue(compile(source("Title"), "example.Person", output));
try (var in = Files.newInputStream(output.resolve(ModelMetadata.resource("example.Person")))) {
var names =
ModelMetadata.read(in).properties().stream().map(ModelMetadata.Property::name).toList();
assertTrue(names.contains("title"));
assertFalse(names.contains("name"));
}
try (var paths = Files.walk(output)) {
for (var p : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p);
}
assertTrue(compile(source("Title"), "", output));
assertFalse(Files.exists(output.resolve(ModelMetadata.resource("example.Person"))));
}
@Test
void dependentCompilationDoesNotDuplicateModelResources() throws Exception {
Path main = root.resolve("main"), tests = root.resolve("tests");
assertTrue(compile(source("Name"), "example.Person", main));
Files.createDirectories(tests);
Path file = root.resolve("Usage.java");
Files.writeString(file, "package example; class Usage { Person value; }");
var compiler = ToolProvider.getSystemJavaCompiler();
try (var files = compiler.getStandardFileManager(null, null, null)) {
var task =
compiler.getTask(
null,
files,
null,
List.of(
"-d",
tests.toString(),
"-classpath",
main.toString(),
"-Adesigner.models=example.Person"),
null,
files.getJavaFileObjects(file.toFile()));
task.setProcessors(List.of(new ModelMetadataProcessor()));
assertTrue(task.call());
}
assertFalse(Files.exists(tests.resolve(ModelMetadata.resource("example.Person"))));
}
@Test
void missingSelectionFailsTheBuild() throws Exception {
assertFalse(compile(source("Name"), "example.Missing", root.resolve("out")));
}
}