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:
+334
@@ -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>();
|
||||
|
||||
+141
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user