Discover Repeat collections and compatible Components in both hosts

This commit is contained in:
2026-11-13 14:47:21 +01:00
parent 0300062fb6
commit 0d14ac14e0
28 changed files with 969 additions and 44 deletions
@@ -23,6 +23,8 @@ public final class BindingDiscovery {
private record TargetType(Class<?> type, boolean writable) {}
private final Map<String, Class<?>> controlTypes = new HashMap<>();
private RepeatDiscovery repeats;
private final Map<String, TargetType> targetTypes = new HashMap<>();
private BindingDiscovery(ClassLoader loader) {
@@ -63,7 +65,7 @@ public final class BindingDiscovery {
return new BindingDiscovery(loader).inspect(fxml, path, direction, control, property, resource);
}
private static Document parse(String xml) throws Exception {
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);
@@ -75,7 +77,7 @@ public final class BindingDiscovery {
return f.newDocumentBuilder().parse(new org.xml.sax.InputSource(new StringReader(xml)));
}
private static String model(Document doc) {
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")) {
@@ -238,6 +240,7 @@ public final class BindingDiscovery {
&& owner.controller != null
? owner.controller
: resolve(e, imports);
controlTypes.put(id, control);
String componentModel = "";
var componentResource = control.getResource(control.getSimpleName() + ".fxml");
if (componentResource != null)
@@ -321,6 +324,7 @@ public final class BindingDiscovery {
bypassesConverter(selected)
? List.<BindingMetadata.Converter>of()
: converters.candidates(source, chosen == null ? null : chosen.type(), direction);
repeats = new RepeatDiscovery(loader, attributes, owner, incompleteModel, controlTypes, ids);
return new BindingMetadata(
model,
selected,
@@ -332,7 +336,10 @@ public final class BindingDiscovery {
validate(doc, imports, ids, converters),
owner.controller == null ? "" : owner.controller.getName(),
owner.viewModel == null ? "" : owner.viewModel.getName(),
owner.methods(chosen == null ? null : chosen.type()));
owner.methods(chosen == null ? null : chosen.type()),
repeats.collections(),
repeats.candidates(selected),
repeats.panes());
}
private void selectOwner(Document doc, List<String> imports, String resource, Class<?> model)
@@ -412,8 +419,9 @@ public final class BindingDiscovery {
var nodes = doc.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
if (!library(e, "DirectBinding", imports) && !library(e, "ComputedBinding", imports))
continue;
if (!library(e, "DirectBinding", imports)
&& !library(e, "ComputedBinding", imports)
&& !library(e, "Repeat", imports)) continue;
Node parent = e.getParentNode();
if (parent instanceof Element p && p.getTagName().equals("elements"))
parent = p.getParentNode();
@@ -423,6 +431,22 @@ public final class BindingDiscovery {
|| !FX.equals(define.getNamespaceURI())
|| !"define".equals(define.getLocalName())
|| define.getParentNode() != doc.getDocumentElement()) continue;
if (library(e, "Repeat", imports)) {
String pane = e.getAttribute("pane");
boolean nested = false;
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element) nested = true;
if (nested)
problems.add(
new Problem(pane, "children", "WARNING", "Nested Repeat requires Source review"));
else {
if (!occupied.add("Repeat:" + pane))
problems.add(new Problem(pane, "children", "ERROR", "Duplicate Repeat pane"));
problems.addAll(
repeats.validate(pane, e.getAttribute("collection"), e.getAttribute("component")));
}
continue;
}
String control = e.getAttribute("control"),
property = e.getAttribute("property"),
key = control + "." + property;
@@ -555,7 +579,7 @@ public final class BindingDiscovery {
return candidates.iterator().next();
}
private static Class<?> propertyType(Type type, Map<TypeVariable<?>, Type> inherited) {
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) {
@@ -0,0 +1,350 @@
package de.neitzel.fxdesigner.target;
import de.neitzel.fxdesigner.protocol.BindingMetadata.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.jar.JarFile;
import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.scene.Parent;
import javafx.scene.layout.Pane;
import org.w3c.dom.*;
/** Signature/resource-only Repeat checks. Never invokes an application accessor or constructor. */
final class RepeatDiscovery {
private final ClassLoader loader;
private final Map<String, RepeatOption> collections = new TreeMap<>();
private final Map<String, Class<?>> controls;
private final Set<String> ids;
private final boolean incomplete;
private final Set<String> nonCollections = new HashSet<>();
private final Map<String, ComponentInfo> components = new TreeMap<>();
private final SortedSet<String> names = new TreeSet<>();
private record ComponentInfo(Class<?> model, Class<?> property, String declared, String error) {}
RepeatDiscovery(
ClassLoader loader,
List<Attribute> attributes,
ControllerMetadata owner,
boolean incomplete,
Map<String, Class<?>> controls,
Set<String> ids)
throws Exception {
this.loader = loader;
this.controls = controls;
this.ids = ids;
this.incomplete = incomplete;
if (!owner.dynamicProperties && !owner.unknownController)
for (var a : attributes)
if (Set.of("VALUE", "BUILTIN", "COMPLEX").contains(a.kind())) nonCollections.add(a.path());
for (var a : attributes) {
if (!a.kind().equals("COLLECTION")
&& !a.kind().equals("VIEWMODEL")
&& !a.kind().equals("UNRESOLVED")) continue;
Class<?> raw;
try {
raw = a.type().equals("?") ? Object.class : load(a.type());
} catch (ClassNotFoundException primitive) {
continue;
}
String element = a.elementType();
boolean wrapped = a.kind().equals("COLLECTION");
if (!wrapped && owner.viewModel != null) {
try {
Method m = owner.viewModel.getMethod(a.path() + "Property");
Type value = argument(m.getGenericReturnType(), ObservableValue.class);
element = element(argument(value, Collection.class));
} catch (NoSuchMethodException ignored) {
}
}
if (raw != Object.class && !Collection.class.isAssignableFrom(raw)) {
if (!owner.dynamicProperties && !owner.unknownController) nonCollections.add(a.path());
continue;
}
if (owner.dynamicProperties || owner.unknownController) element = "";
boolean unknown =
owner.dynamicProperties
|| owner.unknownController
|| element.isEmpty()
|| element.equals(Object.class.getName())
|| (!wrapped && !ObservableList.class.isAssignableFrom(raw));
collections.put(
a.path(),
new RepeatOption(
a.path(),
element.isEmpty() ? "?" : element,
unknown ? "UNVERIFIED" : "COMPATIBLE",
unknown
? "Element type or ObservableList backing is unresolved"
: "Declared collection element type; runtime values may be null"));
}
indexResources();
}
private Class<?> load(String name) throws ClassNotFoundException {
return Class.forName(name, false, loader);
}
List<RepeatOption> collections() {
return List.copyOf(collections.values());
}
List<RepeatOption> panes() {
return ids.stream()
.sorted()
.map(
id -> {
Class<?> type = controls.get(id);
return new RepeatOption(
id,
type == null ? "?" : type.getName(),
type == null
? "UNVERIFIED"
: Pane.class.isAssignableFrom(type) ? "COMPATIBLE" : "INCOMPATIBLE",
type == null
? "Pane type unavailable"
: Pane.class.isAssignableFrom(type)
? "JavaFX Pane"
: "Repeat requires a JavaFX Pane");
})
.toList();
}
List<RepeatOption> candidates(String path) {
return names.stream().map(n -> component(n, path)).toList();
}
RepeatOption collection(String path) {
if (nonCollections.contains(path))
return new RepeatOption(path, "?", "INCOMPATIBLE", "Repeat requires a collection: " + path);
var c = collections.get(path);
return c != null
? c
: new RepeatOption(
path,
"?",
incomplete ? "UNVERIFIED" : "INCOMPATIBLE",
incomplete ? "Collection path unresolved" : "No discoverable collection: " + path);
}
RepeatOption component(String name, String path) {
ComponentInfo info = components.computeIfAbsent(name, this::readComponent);
if (info.error() != null)
return new RepeatOption(name, info.declared(), "INCOMPATIBLE", info.error());
var source = collection(path);
if (source.status().equals("INCOMPATIBLE"))
return new RepeatOption(
name, info.declared(), "UNVERIFIED", "Choose a collection to compare element types");
Class<?> element;
try {
element = source.type().equals("?") ? null : load(source.type());
} catch (ClassNotFoundException e) {
element = null;
}
if (element != null && element != Object.class) {
for (var constraint : Arrays.asList(info.model(), info.property()))
if (constraint != null
&& constraint != Object.class
&& !constraint.isAssignableFrom(element))
return new RepeatOption(
name,
info.declared(),
"INCOMPATIBLE",
"Collection element "
+ element.getName()
+ " is incompatible with Component Model "
+ constraint.getName());
}
boolean verified =
source.status().equals("COMPATIBLE")
&& element != null
&& element != Object.class
&& (info.model() != null || info.property() != null && info.property() != Object.class);
return new RepeatOption(
name,
info.declared(),
verified ? "COMPATIBLE" : "UNVERIFIED",
verified
? "Declared element fits Component Model; construction remains a runtime check"
: "Collection or Component Model type is unresolved");
}
List<Problem> validate(String pane, String path, String component) {
var problems = new ArrayList<Problem>();
if (!ids.contains(pane))
problems.add(new Problem(pane, "children", "ERROR", "Unknown Repeat pane: " + pane));
else if (!controls.containsKey(pane))
problems.add(new Problem(pane, "children", "WARNING", "Repeat pane type unavailable"));
else if (!Pane.class.isAssignableFrom(controls.get(pane)))
problems.add(new Problem(pane, "children", "ERROR", "Repeat requires a JavaFX Pane"));
var c = collection(path);
if (!c.status().equals("COMPATIBLE"))
problems.add(
new Problem(
pane,
"children",
c.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING",
c.reason()));
var target = component(component, path);
if (!target.status().equals("COMPATIBLE"))
problems.add(
new Problem(
pane,
"children",
target.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING",
target.reason()));
return problems;
}
private ComponentInfo readComponent(String name) {
String declared = "?";
try {
Class<?> type = load(name);
if (!Parent.class.isAssignableFrom(type)
|| !Modifier.isPublic(type.getModifiers())
|| Modifier.isAbstract(type.getModifiers()))
throw new IllegalArgumentException(
"Repeat Component must be a public concrete JavaFX Parent");
Method accessor = type.getMethod("modelProperty");
if (!Property.class.isAssignableFrom(accessor.getReturnType())
|| !ControllerMetadata.accessible(accessor.getDeclaringClass(), true))
throw new IllegalArgumentException("Component needs an accessible writable modelProperty");
Class<?> property = BindingDiscovery.propertyType(accessor.getGenericReturnType(), Map.of());
var resource = type.getResource(type.getSimpleName() + ".fxml");
if (resource == null)
throw new IllegalArgumentException("Component FXML unavailable: " + name);
Document doc;
try (var in = resource.openStream()) {
byte[] bytes = in.readNBytes(512 * 1024 + 1);
if (bytes.length > 512 * 1024) throw new IOException("Component FXML exceeds limit");
doc = BindingDiscovery.parse(new String(bytes, StandardCharsets.UTF_8));
}
Element root = doc.getDocumentElement();
if (!"http://javafx.com/fxml/1".equals(root.getNamespaceURI())
|| !"root".equals(root.getLocalName()))
throw new IllegalArgumentException("Component FXML requires fx:root");
if (root.hasAttributeNS("http://javafx.com/fxml/1", "controller"))
throw new IllegalArgumentException("Component FXML must not override its Controller");
String base = root.getAttribute("type");
var roots = new HashSet<Class<?>>();
if (base.contains(".")) roots.add(load(base));
else
for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) {
String imp = pi.getData().trim();
try {
if (imp.endsWith(".*")) roots.add(load(imp.substring(0, imp.length() - 1) + base));
else if (imp.endsWith("." + base)) roots.add(load(imp));
} catch (ClassNotFoundException ignored) {
}
}
if (roots.size() != 1 || !roots.iterator().next().isAssignableFrom(type))
throw new IllegalArgumentException("Component fx:root type is incompatible or unresolved");
declared = BindingDiscovery.model(doc);
Class<?> model = declared.isEmpty() ? null : load(declared);
if (declared.isEmpty()) declared = property == null ? "?" : property.getName();
return new ComponentInfo(model, property, declared, null);
} catch (Exception | LinkageError e) {
return new ComponentInfo(
null, null, declared, "Component unavailable: " + name + ": " + e.getMessage());
}
}
private void indexResources() throws IOException {
var entries = new LinkedHashSet<String>();
for (String key : List.of("java.class.path", "jdk.module.path"))
entries.addAll(Arrays.asList(System.getProperty(key, "").split(File.pathSeparator)));
int count = 0;
for (String entry : entries) {
if (entry.isEmpty()) continue;
Path path = Path.of(entry);
if (Files.isDirectory(path)) {
try (var files = Files.walk(path)) {
var it = files.filter(p -> p.toString().endsWith(".fxml")).iterator();
while (it.hasNext()) {
if (++count > 4096) throw new IOException("Too many Component resources");
index(path.relativize(it.next()).toString().replace(File.separatorChar, '/'));
}
}
} else if (Files.isRegularFile(path) && entry.endsWith(".jar")) {
try (var jar = new JarFile(path.toFile(), false, JarFile.OPEN_READ, Runtime.version())) {
var it = jar.versionedStream().filter(e -> e.getName().endsWith(".fxml")).iterator();
while (it.hasNext()) {
if (++count > 4096) throw new IOException("Too many Component resources");
index(it.next().getName());
}
}
}
}
}
private void index(String resource) throws IOException {
if (resource.startsWith("META-INF/")) return;
String name = resource.substring(0, resource.length() - 5).replace('/', '.');
try {
Class<?> type = load(name);
if (Parent.class.isAssignableFrom(type)) names.add(name);
if (names.size() > 256) throw new IOException("Too many Repeat Components");
} catch (ClassNotFoundException | LinkageError ignored) {
}
}
private record Parameterized(Type raw, Type[] arguments) implements ParameterizedType {
public Type getRawType() {
return raw;
}
public Type[] getActualTypeArguments() {
return arguments.clone();
}
public Type getOwnerType() {
return null;
}
}
private static Type substitute(Type t, Map<TypeVariable<?>, Type> vars) {
if (t instanceof TypeVariable<?> v) return vars.getOrDefault(v, v);
if (t instanceof ParameterizedType p)
return new Parameterized(
p.getRawType(),
Arrays.stream(p.getActualTypeArguments())
.map(a -> substitute(a, vars))
.toArray(Type[]::new));
return t;
}
private static Type argument(Type type, Class<?> target) {
if (type == null) return null;
Class<?> raw;
var vars = new HashMap<TypeVariable<?>, Type>();
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) {
raw = c;
for (int i = 0; i < c.getTypeParameters().length; i++)
vars.put(c.getTypeParameters()[i], p.getActualTypeArguments()[i]);
} else if (type instanceof Class<?> c) raw = c;
else return null;
if (raw == target) return vars.get(raw.getTypeParameters()[0]);
for (Type parent : raw.getGenericInterfaces()) {
Type value = argument(substitute(parent, vars), target);
if (value != null) return value;
}
return raw.getGenericSuperclass() == null
? null
: argument(substitute(raw.getGenericSuperclass(), vars), target);
}
private static String element(Type t) {
if (t instanceof Class<?> c) return c.getName();
if (t instanceof WildcardType w
&& w.getLowerBounds().length == 0
&& w.getUpperBounds().length == 1
&& w.getUpperBounds()[0] instanceof Class<?> c) return c.getName();
return "";
}
}
@@ -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", "bindings-v3"),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v4"),
Set.of()));
var welcome = FrameCodec.read(socket.getInputStream());
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1)
@@ -0,0 +1,170 @@
package de.neitzel.fxdesigner.target;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fx.view.*;
import java.util.*;
import javafx.beans.property.*;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.Test;
class RepeatDiscoveryTest {
public static class Item {}
public static class Model {
static {
System.setProperty("repeat.model.initialized", "yes");
}
public List<Item> getItems() {
throw new AssertionError("getter");
}
public void setItems(List<Item> v) {}
public List getRaw() {
throw new AssertionError("getter");
}
public void setRaw(List v) {}
public String getName() {
throw new AssertionError("getter");
}
public void setName(String v) {}
}
public static class Replacement {
public Replacement(Model m) {
throw new AssertionError("constructor");
}
public ListProperty<Item> itemsProperty() {
throw new AssertionError("accessor");
}
public ObjectProperty<List<Item>> uncertainProperty() {
throw new AssertionError("accessor");
}
public ObjectProperty<String> nameProperty() {
throw new AssertionError("accessor");
}
}
@ViewModelClass(Replacement.class)
public static class Controller {}
public static class Good extends VBox {
static {
System.setProperty("repeat.component.initialized", "yes");
}
public ObjectProperty<Item> modelProperty() {
throw new AssertionError("accessor");
}
}
public static class Wrong extends VBox {
public ObjectProperty<Model> modelProperty() {
throw new AssertionError("accessor");
}
}
public static class Unknown extends VBox {
public ObjectProperty<Object> modelProperty() {
throw new AssertionError("accessor");
}
}
public static class ReadOnly extends VBox {
public ReadOnlyObjectProperty<Item> modelProperty() {
throw new AssertionError("accessor");
}
}
private String xml(String collection, String component, String pane, String controller) {
return "<?neitzel-model "
+ Model.class.getName()
+ "?><?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?>"
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'"
+ (controller.isEmpty() ? "" : " fx:controller='" + controller + "'")
+ "><fx:define><de.neitzel.fx.view.BindingList>"
+ "<de.neitzel.fx.view.Repeat pane='"
+ pane
+ "' collection='"
+ collection
+ "' component='"
+ component
+ "'/></de.neitzel.fx.view.BindingList></fx:define><VBox fx:id='rows'/><Label"
+ " fx:id='label'/></VBox>";
}
private de.neitzel.fxdesigner.protocol.BindingMetadata inspect(
String path, Class<?> type, String pane, String controller) throws Exception {
return BindingDiscovery.inspect(
xml(path, type.getName(), pane, controller), path, "view", getClass().getClassLoader());
}
@Test
void acceptsDeclaredTypesWithoutInitializingApplicationCode() throws Exception {
var m = inspect("items", Good.class, "rows", "");
assertTrue(m.problems().isEmpty(), m.problems().toString());
assertTrue(
m.collections().stream()
.anyMatch(
c ->
c.name().equals("items")
&& c.type().equals(Item.class.getName())
&& c.status().equals("COMPATIBLE")));
assertTrue(
m.panes().stream()
.anyMatch(p -> p.name().equals("label") && p.status().equals("INCOMPATIBLE")));
assertNull(System.getProperty("repeat.model.initialized"));
assertNull(System.getProperty("repeat.component.initialized"));
}
@Test
void rejectsWrongModelPaneAndMissingCollection() throws Exception {
assertTrue(
inspect("items", Wrong.class, "rows", "").problems().stream()
.anyMatch(p -> p.severity().equals("ERROR") && p.message().contains("incompatible")));
assertTrue(
inspect("items", Good.class, "label", "").problems().stream()
.anyMatch(p -> p.message().contains("JavaFX Pane")));
assertTrue(
inspect("name", Good.class, "rows", "").problems().stream()
.anyMatch(p -> p.severity().equals("ERROR") && p.message().contains("collection")));
assertTrue(
inspect("absent", Good.class, "rows", "").problems().stream()
.anyMatch(p -> p.severity().equals("ERROR")));
assertTrue(
inspect("items", ReadOnly.class, "rows", "").problems().stream()
.anyMatch(p -> p.message().contains("modelProperty")));
}
@Test
void keepsUnknownElementAndComponentModelsUnverified() throws Exception {
for (var m :
List.of(
inspect("raw", Good.class, "rows", ""), inspect("items", Unknown.class, "rows", ""))) {
assertTrue(m.problems().stream().anyMatch(p -> p.severity().equals("WARNING")));
assertTrue(
m.problems().stream().noneMatch(p -> p.severity().equals("ERROR")),
m.problems().toString());
}
}
@Test
void discoversReplacementObservableCollectionsAndWarnsForPlainListBacking() throws Exception {
var m = inspect("items", Good.class, "rows", Controller.class.getName());
assertTrue(m.problems().isEmpty(), m.problems().toString());
assertTrue(
m.collections().stream()
.anyMatch(c -> c.name().equals("items") && c.status().equals("COMPATIBLE")));
assertTrue(m.collections().stream().noneMatch(c -> c.name().equals("raw")));
var uncertain = inspect("uncertain", Good.class, "rows", Controller.class.getName());
assertTrue(uncertain.problems().stream().anyMatch(p -> p.severity().equals("WARNING")));
}
}
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>
@@ -0,0 +1 @@
<fx:root xmlns:fx="http://javafx.com/fxml/1" type="javafx.scene.layout.VBox"/>