Discover Model paths and compatible binding targets in both hosts

Add non-constructing target metadata and a shared read-only browser for the Person fixtures. Negotiate binding discovery independently, preserve FXML, and explain direction/type mismatches. Record remaining manual navigation checks as explicitly skipped.
This commit is contained in:
2026-11-13 08:57:35 +01:00
parent 2156894fa8
commit 2fe408bb4d
38 changed files with 1087 additions and 37 deletions
@@ -0,0 +1,334 @@
package de.neitzel.fxdesigner.target;
import de.neitzel.fxdesigner.protocol.BindingMetadata;
import de.neitzel.fxdesigner.protocol.BindingMetadata.*;
import java.beans.*;
import java.io.*;
import java.lang.reflect.*;
import java.util.*;
import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import org.w3c.dom.*;
/** Signature-only fallback. Never invokes a getter, property accessor, provider or constructor. */
public final class BindingDiscovery {
private static final String FX = "http://javafx.com/fxml/1";
private final ClassLoader loader;
private final List<String> diagnostics = new ArrayList<>();
private final Map<String, Class<?>> paths = new TreeMap<>();
private final List<Attribute> attributes = new ArrayList<>();
private BindingDiscovery(ClassLoader loader) {
this.loader = loader;
}
public static BindingMetadata inspect(
String fxml, String path, String direction, ClassLoader loader) throws Exception {
if (!Set.of("view", "model", "both").contains(direction))
throw new IllegalArgumentException("Invalid direction");
return new BindingDiscovery(loader).inspect(fxml, path, direction);
}
private static Document parse(String xml) throws Exception {
if (xml.length() > 512 * 1024) throw new IOException("FXML metadata input exceeds limit");
var f = javax.xml.parsers.DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
return f.newDocumentBuilder().parse(new org.xml.sax.InputSource(new StringReader(xml)));
}
private static String model(Document doc) {
String model = "";
for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("neitzel-model")) {
if (!model.isEmpty()) throw new IllegalArgumentException("Duplicate Model declaration");
model = pi.getData().trim();
if (!model.matches("[\\w$]+(\\.[\\w$]+)+"))
throw new IllegalArgumentException("Invalid Model declaration");
}
return model;
}
private Class<?> load(String name) throws ClassNotFoundException {
return Class.forName(name, false, loader);
}
private List<PropertyDescriptor> beans(Class<?> type) throws Exception {
if (type == Object.class || type.isInterface()) return List.of();
// Explicit BeanInfo can execute application code; it is not a safe fallback source.
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
try {
load(c.getName() + "BeanInfo");
diagnostics.add("Custom BeanInfo requires generated metadata: " + c.getName());
return List.of();
} catch (ClassNotFoundException absent) {
}
}
return Arrays.stream(
Introspector.getBeanInfo(type, Object.class, Introspector.IGNORE_ALL_BEANINFO)
.getPropertyDescriptors())
.sorted(Comparator.comparing(PropertyDescriptor::getName))
.toList();
}
private static boolean simple(Class<?> c) {
return c.isPrimitive()
|| c.isEnum()
|| c.isArray()
|| c == String.class
|| c == Boolean.class
|| Number.class.isAssignableFrom(c)
|| Collection.class.isAssignableFrom(c)
|| Map.class.isAssignableFrom(c)
|| Date.class.isAssignableFrom(c)
|| Calendar.class.isAssignableFrom(c)
|| c.getPackageName().startsWith("java.time");
}
private static String element(Type t) {
if (t instanceof ParameterizedType p && p.getActualTypeArguments().length == 1) {
Type a = p.getActualTypeArguments()[0];
if (a instanceof Class<?> c) return c.getName();
if (a instanceof WildcardType w
&& w.getLowerBounds().length == 0
&& w.getUpperBounds().length == 1
&& w.getUpperBounds()[0] instanceof Class<?> c) return c.getName();
}
return "";
}
private void attributes(Class<?> type, String prefix, Set<Class<?>> ancestors, int depth)
throws Exception {
if (depth > 4 || ancestors.contains(type)) {
diagnostics.add("Nested path expansion stopped at " + prefix);
return;
}
var next = new HashSet<>(ancestors);
next.add(type);
for (var p : beans(type)) {
if (attributes.size() >= 256) throw new IOException("Too many Model paths");
if (p.getReadMethod() == null || p.getWriteMethod() == null || p.getPropertyType() == null)
continue;
Class<?> c = p.getPropertyType();
String path = prefix + p.getName();
String kind =
Collection.class.isAssignableFrom(c)
? "COLLECTION"
: c == Object.class
? "UNRESOLVED"
: !simple(c)
&& beans(c).stream()
.anyMatch(
b -> b.getReadMethod() != null && b.getWriteMethod() != null)
? "COMPLEX"
: "VALUE";
paths.put(path, c);
attributes.add(
new Attribute(
path,
c.getName(),
kind,
kind.equals("COLLECTION") ? element(p.getReadMethod().getGenericReturnType()) : ""));
if (kind.equals("COMPLEX")) attributes(c, path + ".", next, depth + 1);
}
}
private BindingMetadata inspect(String xml, String selected, String direction) throws Exception {
Document doc = parse(xml);
String model = model(doc);
if (model.isEmpty())
return new BindingMetadata(
"",
"",
direction,
List.of(),
List.of(),
List.of(
"Declare this View's Model with <?neitzel-model package.Model?> before discovering"
+ " bindings."));
attributes(load(model), "", Set.of(), 0);
if (selected.isEmpty())
selected =
paths.containsKey("name") ? "name" : paths.keySet().stream().findFirst().orElse("");
Class<?> source = paths.get(selected);
if (source == null && !selected.isEmpty()) diagnostics.add("Unknown Model path: " + selected);
var imports = new ArrayList<String>();
for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import"))
imports.add(pi.getData().trim());
var targets = new ArrayList<Target>();
var ids = new HashSet<String>();
var nodes = doc.getElementsByTagName("*");
if (nodes.getLength() > 4096) throw new IOException("Too many FXML elements");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String id = e.getAttributeNS(FX, "id");
if (id.isEmpty() || defined(e)) continue;
if (!ids.add(id)) throw new IllegalArgumentException("Duplicate fx:id: " + id);
try {
Class<?> control = resolve(e, imports);
String componentModel = "";
var resource = control.getResource(control.getSimpleName() + ".fxml");
if (resource != null)
try (var in = resource.openStream()) {
byte[] b = in.readNBytes(512 * 1024 + 1);
if (b.length > 512 * 1024) throw new IOException("Component FXML exceeds limit");
componentModel = model(parse(new String(b, java.nio.charset.StandardCharsets.UTF_8)));
}
for (Method m : control.getMethods()) {
if (Modifier.isStatic(m.getModifiers())
|| m.getParameterCount() != 0
|| !m.getName().endsWith("Property")
|| !ObservableValue.class.isAssignableFrom(m.getReturnType())) continue;
String property = m.getName().substring(0, m.getName().length() - 8);
if (property.isEmpty()) continue;
Class<?> getter = valueType(control, property);
Class<?> value = propertyType(m.getGenericReturnType(), Map.of());
if (value == Number.class && getter != null && Number.class.isAssignableFrom(box(getter)))
value = getter;
else if (value != null && getter != null && box(value) != box(getter)) value = null;
boolean constrained = false;
if (property.equals("model") && !componentModel.isEmpty()) {
value = load(componentModel);
constrained = true;
}
String status, reason;
if (!Property.class.isAssignableFrom(m.getReturnType())) {
status = "INCOMPATIBLE";
reason = "Runtime bindings require a writable JavaFX Property accessor.";
} else if (source == null
|| source == Object.class
|| value == null
|| value == Object.class) {
status = "UNRESOLVED";
reason =
"Declared types are insufficient; rebuild with specific signatures/Model metadata.";
} else if (Collection.class.isAssignableFrom(source)
|| Collection.class.isAssignableFrom(value)
|| Map.class.isAssignableFrom(source)
|| Map.class.isAssignableFrom(value)) {
status = "UNRESOLVED";
reason =
"Collection variance requires further metadata; use Repeat/Table for Model"
+ " collections.";
} else {
Class<?> a = box(source), b = box(value);
boolean view = b.isAssignableFrom(a), back = a.isAssignableFrom(b);
boolean ok =
direction.equals("view") ? view : direction.equals("model") ? back : view && back;
status = ok ? "COMPATIBLE" : "CONVERTER_REQUIRED";
reason =
ok
? (constrained
? "Compatible with the Component's declared Model."
: "Declared types support " + direction + " binding.")
: "Declared types do not support "
+ direction
+ " binding; a compatible Converter is required.";
}
targets.add(
new Target(id, property, value == null ? "?" : value.getName(), status, reason));
if (targets.size() > 2048) throw new IOException("Too many binding targets");
}
} catch (ClassNotFoundException | LinkageError ex) {
diagnostics.add(id + ": unavailable target type: " + ex.getMessage());
}
}
targets.sort(
Comparator.comparingInt(
(Target t) ->
List.of("COMPATIBLE", "UNRESOLVED", "CONVERTER_REQUIRED", "INCOMPATIBLE")
.indexOf(t.status()))
.thenComparing(Target::control)
.thenComparing(Target::property));
return new BindingMetadata(model, selected, direction, attributes, targets, diagnostics);
}
private static boolean defined(Element e) {
for (Node n = e; n instanceof Element a; n = n.getParentNode())
if (FX.equals(a.getNamespaceURI()) && a.getLocalName().equals("define")) return true;
return false;
}
private Class<?> resolve(Element e, List<String> imports) throws Exception {
String tag = e.getTagName();
if (FX.equals(e.getNamespaceURI()) && e.getLocalName().equals("root"))
tag = e.getAttribute("type");
if (tag.contains(".")) return load(tag);
var candidates = new HashSet<Class<?>>();
for (String imp : imports) {
try {
if (imp.endsWith(".*")) candidates.add(load(imp.substring(0, imp.length() - 1) + tag));
else if (imp.endsWith("." + tag)) candidates.add(load(imp));
} catch (ClassNotFoundException ignored) {
}
}
if (candidates.size() != 1) throw new ClassNotFoundException("No unique import for " + tag);
return candidates.iterator().next();
}
private static Class<?> propertyType(Type type, Map<TypeVariable<?>, Type> inherited) {
Class<?> raw;
var bindings = new HashMap<>(inherited);
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) {
raw = c;
for (int i = 0; i < c.getTypeParameters().length; i++) {
Type argument = p.getActualTypeArguments()[i];
if (argument instanceof TypeVariable<?> v) argument = bindings.getOrDefault(v, v);
bindings.put(c.getTypeParameters()[i], argument);
}
} else if (type instanceof Class<?> c) raw = c;
else return null;
if (raw == ObservableValue.class) {
Type value = bindings.get(raw.getTypeParameters()[0]);
return value instanceof Class<?> c
? c
: value instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c ? c : null;
}
for (Type parent : raw.getGenericInterfaces()) {
Class<?> value = propertyType(parent, bindings);
if (value != null) return value;
}
return raw.getGenericSuperclass() == null
? null
: propertyType(raw.getGenericSuperclass(), bindings);
}
private static Class<?> valueType(Class<?> control, String name) {
String suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1);
for (String prefix : List.of("get", "is"))
try {
Method m = control.getMethod(prefix + suffix);
if (!Modifier.isStatic(m.getModifiers()) && m.getReturnType() != void.class)
return m.getReturnType();
} catch (NoSuchMethodException ignored) {
}
return null;
}
private static Class<?> box(Class<?> c) {
if (!c.isPrimitive()) return c;
return Map.<Class<?>, Class<?>>of(
int.class,
Integer.class,
long.class,
Long.class,
double.class,
Double.class,
float.class,
Float.class,
boolean.class,
Boolean.class,
byte.class,
Byte.class,
short.class,
Short.class,
char.class,
Character.class)
.getOrDefault(c, c);
}
}
@@ -92,7 +92,7 @@ public final class WorkerMain {
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1"),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v1"),
Set.of()));
var welcome = FrameCodec.read(socket.getInputStream());
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1)
@@ -113,7 +113,21 @@ public final class WorkerMain {
throw new IOException("Unnegotiated operation");
try {
var loader = WorkerMain.class.getClassLoader();
if (p.operation() == Operation.DISCOVER) {
if (p.operation() == Operation.BINDINGS) {
var input = PreviewMessages.decode(p.payload());
if (!input.keySet().equals(Set.of("fxml", "path", "direction")))
throw new IOException("Invalid binding discovery input");
var metadata =
BindingDiscovery.inspect(
input.get("fxml"), input.get("path"), input.get("direction"), loader);
send(
socket,
new Packet(
Kind.RESULT,
p.operation(),
p.identity(),
PreviewMessages.encode(metadata.encode())));
} else if (p.operation() == Operation.DISCOVER) {
if (p.payload().length != 0) throw new IOException("Discovery payload must be empty");
var entries = ExampleCatalog.discover(loader);
var data = new TreeMap<String, String>();
@@ -0,0 +1,141 @@
package de.neitzel.fxdesigner.target;
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.junit.jupiter.api.Test;
class BindingDiscoveryTest {
public static class Address {
public String getStreet() {
throw new AssertionError("Getter executed");
}
public void setStreet(String s) {
throw new AssertionError("Setter executed");
}
}
public static class Model {
static {
System.setProperty("discovery.model.initialized", "yes");
}
public Model() {
throw new AssertionError("Model constructed");
}
public String getName() {
throw new AssertionError("Getter executed");
}
public void setName(String s) {}
public int getRank() {
return 0;
}
public void setRank(int n) {}
public Address getHome() {
return null;
}
public void setHome(Address a) {}
public List<Address> getVisits() {
return null;
}
public void setVisits(List<Address> a) {}
public String getReadOnly() {
return "";
}
}
private String fxml() {
return "<?neitzel-model "
+ Model.class.getName()
+ "?><?import javafx.scene.control.TextField?><TextField"
+ " xmlns:fx=\"http://javafx.com/fxml/1\" fx:id=\"input\"/>";
}
@Test
void discoversNestedAndCollectionSignaturesWithoutApplicationOrToolkitInitialization()
throws Exception {
var metadata = BindingDiscovery.inspect(fxml(), "name", "both", getClass().getClassLoader());
assertEquals(
Set.of("home", "home.street", "name", "rank", "visits"),
metadata.attributes().stream()
.map(a -> a.path())
.collect(java.util.stream.Collectors.toSet()));
assertEquals(
Address.class.getName(),
metadata.attributes().stream()
.filter(a -> a.path().equals("visits"))
.findFirst()
.orElseThrow()
.elementType());
assertTrue(
metadata.targets().stream()
.anyMatch(t -> t.property().equals("text") && t.status().equals("COMPATIBLE")));
assertTrue(
metadata.targets().stream()
.anyMatch(t -> t.property().equals("width") && t.status().equals("INCOMPATIBLE")));
assertNull(System.getProperty("discovery.model.initialized"));
assertThrows(IllegalStateException.class, () -> javafx.application.Platform.runLater(() -> {}));
}
@Test
void explainsMismatchesAndRequiresExplicitModel() throws Exception {
var result = BindingDiscovery.inspect(fxml(), "rank", "view", getClass().getClassLoader());
assertTrue(
result.targets().stream()
.anyMatch(t -> t.property().equals("text") && t.status().equals("CONVERTER_REQUIRED")));
assertTrue(
BindingDiscovery.inspect("<VBox/>", "", "both", getClass().getClassLoader())
.diagnostics()
.getFirst()
.contains("neitzel-model"));
assertThrows(
Exception.class,
() ->
BindingDiscovery.inspect(
"<!DOCTYPE x SYSTEM 'file:///etc/passwd'><x/>",
"",
"both",
getClass().getClassLoader()));
assertThrows(
IllegalArgumentException.class,
() -> BindingDiscovery.inspect(fxml(), "", "wrong", getClass().getClassLoader()));
}
public static class NumberModel {
public Number getAmount() {
return null;
}
public void setAmount(Number n) {}
}
@Test
void compatibilityDependsOnDirection() throws Exception {
String xml =
"<?neitzel-model "
+ NumberModel.class.getName()
+ "?><?import javafx.scene.control.ProgressBar?><ProgressBar"
+ " xmlns:fx=\"http://javafx.com/fxml/1\" fx:id=\"progress\"/>";
for (String direction : List.of("view", "model", "both")) {
var metadata =
BindingDiscovery.inspect(xml, "amount", direction, getClass().getClassLoader());
var target =
metadata.targets().stream()
.filter(t -> t.property().equals("progress"))
.findFirst()
.orElseThrow();
assertEquals(
direction.equals("model") ? "COMPATIBLE" : "CONVERTER_REQUIRED", target.status());
}
}
}