diff --git a/README.md b/README.md index a219d1f..e855a40 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits. -The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Broader binding authoring, generation, structural editing and product packaging remain later work. +The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. Broader binding authoring, generation, structural editing and product packaging remain later work. Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules. diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java index 3ba1079..21a5a8a 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java @@ -9,7 +9,27 @@ public record BindingMetadata( String direction, List attributes, List targets, - List diagnostics) { + List diagnostics, + List converters, + List problems) { + public record Converter(String name, String status, String reason) { + public String toString() { + return name + " — " + status + ": " + reason; + } + } + + public record Problem(String control, String property, String severity, String message) {} + + public BindingMetadata( + String model, + String path, + String direction, + List attributes, + List targets, + List diagnostics) { + this(model, path, direction, attributes, targets, diagnostics, List.of(), List.of()); + } + public record Attribute(String path, String type, String kind, String elementType) { public String toString() { return path + " : " + type + " (" + kind + ")"; @@ -23,13 +43,17 @@ public record BindingMetadata( attributes = List.copyOf(attributes); targets = List.copyOf(targets); diagnostics = List.copyOf(diagnostics); + converters = List.copyOf(converters); + problems = List.copyOf(problems); if (!Set.of("view", "model", "both").contains(direction)) throw new IllegalArgumentException("Invalid direction"); } public Map encode() { var m = new TreeMap(); - m.put("format", "1"); + m.put("format", "2"); + m.put("converters", "" + converters.size()); + m.put("problems", "" + problems.size()); m.put("model", model); m.put("path", path); m.put("direction", direction); @@ -54,6 +78,21 @@ public record BindingMetadata( m.put(p + "reason", t.reason); } for (int i = 0; i < diagnostics.size(); i++) m.put("d." + i, diagnostics.get(i)); + for (int i = 0; i < converters.size(); i++) { + var c = converters.get(i); + String p = "c." + i + "."; + m.put(p + "name", c.name()); + m.put(p + "status", c.status()); + m.put(p + "reason", c.reason()); + } + for (int i = 0; i < problems.size(); i++) { + var v = problems.get(i); + String p = "v." + i + "."; + m.put(p + "control", v.control()); + m.put(p + "property", v.property()); + m.put(p + "severity", v.severity()); + m.put(p + "message", v.message()); + } return m; } @@ -68,7 +107,7 @@ public record BindingMetadata( } public static BindingMetadata decode(Map m) { - if (!"1".equals(field(m, "format"))) + if (!"2".equals(field(m, "format"))) throw new IllegalArgumentException("Unsupported binding metadata"); var a = new ArrayList(); var t = new ArrayList(); @@ -93,6 +132,22 @@ public record BindingMetadata( field(m, p + "reason"))); } for (int i = 0; i < count(m, "diagnostics"); i++) d.add(field(m, "d." + i)); - return new BindingMetadata(field(m, "model"), field(m, "path"), field(m, "direction"), a, t, d); + var c = new ArrayList(); + var v = new ArrayList(); + for (int i = 0; i < count(m, "converters"); i++) { + String p = "c." + i + "."; + c.add(new Converter(field(m, p + "name"), field(m, p + "status"), field(m, p + "reason"))); + } + for (int i = 0; i < count(m, "problems"); i++) { + String p = "v." + i + "."; + v.add( + new Problem( + field(m, p + "control"), + field(m, p + "property"), + field(m, p + "severity"), + field(m, p + "message"))); + } + return new BindingMetadata( + field(m, "model"), field(m, "path"), field(m, "direction"), a, t, d, c, v); } } diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java index 8200eb7..ff1cbc2 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java @@ -82,8 +82,8 @@ public final class ProtocolSession implements AutoCloseable { && supported.contains("interactive-v1") && hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE); if (hello.metadata().major() == 1 - && supported.contains("bindings-v1") - && hello.capabilities().contains("bindings-v1")) usable.add(Operation.BINDINGS); + && supported.contains("bindings-v2") + && hello.capabilities().contains("bindings-v2")) usable.add(Operation.BINDINGS); var requiredByEither = new HashSet<>(required); requiredByEither.addAll(hello.required()); if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER)) @@ -92,7 +92,7 @@ public final class ProtocolSession implements AutoCloseable { if ((requiredByEither.contains("layout-v1") && !usable.contains(Operation.MEASURE)) || (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE))) throw new IllegalArgumentException("Required capability version incompatible"); - if (requiredByEither.contains("bindings-v1") && !usable.contains(Operation.BINDINGS)) + if (requiredByEither.contains("bindings-v2") && !usable.contains(Operation.BINDINGS)) throw new IllegalArgumentException("Required capability version incompatible"); operations = Set.copyOf(usable); state = State.READY; diff --git a/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java index b236846..f1612d0 100644 --- a/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java +++ b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java @@ -19,7 +19,9 @@ class BindingMetadataTest { List.of( new BindingMetadata.Target( "input", "text", "java.lang.String", "COMPATIBLE", "Types match")), - List.of()); + List.of(), + List.of(new BindingMetadata.Converter("app.C", "UNVERIFIED", "No metadata")), + List.of(new BindingMetadata.Problem("input", "text", "ERROR", "Missing path"))); assertEquals( m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode())))); var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0); @@ -34,14 +36,14 @@ class BindingMetadataTest { new Identity("s", "r", "c", "b", Map.of(), 0, 0), new byte[0])); var invalid = new HashMap<>(m.encode()); - invalid.put("targets", "99999"); + invalid.put("converters", "99999"); assertThrows(IllegalArgumentException.class, () -> BindingMetadata.decode(invalid)); } @Test void negotiationRequiresMetadataVersion() throws Exception { String token = ProtocolSession.newToken(); - var session = new ProtocolSession("s", 1, token, Set.of("bindings-v1"), Set.of("bindings-v1")); + var session = new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2")); assertTrue( session .authenticate( @@ -53,11 +55,27 @@ class BindingMetadataTest { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("bindings-v1"), + Set.of("bindings-v2"), Set.of())) .contains(Operation.BINDINGS)); + var oldWorker = + new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2")); + assertThrows( + IllegalArgumentException.class, + () -> + oldWorker.authenticate( + InetAddress.getLoopbackAddress(), + new Hello( + "s", + 1, + token, + new Version(1, 0), + new Version(1, 0), + new Version(1, 0), + Set.of("bindings-v1"), + Set.of()))); var incompatible = - new ProtocolSession("s", 1, token, Set.of("bindings-v1"), Set.of("bindings-v1")); + new ProtocolSession("s", 1, token, Set.of("bindings-v2"), Set.of("bindings-v2")); assertThrows( IllegalArgumentException.class, () -> @@ -70,7 +88,7 @@ class BindingMetadataTest { new Version(1, 0), new Version(2, 0), new Version(1, 0), - Set.of("bindings-v1"), + Set.of("bindings-v2"), Set.of()))); } } diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java index bcc2a9d..dc010c7 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java @@ -200,6 +200,16 @@ public final class DesignerWorkflow implements AutoCloseable { public CompletionStage bindings( boolean last, DocumentSnapshot document, String path, String direction) { + return bindingDetails(last, document, path, direction, "", ""); + } + + public CompletionStage bindingDetails( + boolean last, + DocumentSnapshot document, + String path, + String direction, + String control, + String property) { return execute( last, List.of(document), @@ -211,7 +221,17 @@ public final class DesignerWorkflow implements AutoCloseable { 0, 0, PreviewMessages.encode( - Map.of("fxml", document.text(), "path", path, "direction", direction)), + Map.of( + "fxml", + document.text(), + "path", + path, + "direction", + direction, + "control", + control, + "property", + property)), Duration.ofSeconds(15)); try (var current = host.documents().open(document.document()).toCompletableFuture().get()) { diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java index f5585f9..3675964 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java @@ -104,7 +104,7 @@ public final class PreviewWorker implements AutoCloseable { id, process.pid(), token, - Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v1"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v2"), config.requiredCapabilities()); } drain(process.getInputStream(), log); diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java index ccb4abf..eca5e01 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java @@ -115,6 +115,10 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private DesignerWorkflow bindingWorkflow; private BindingBrowser bindingBrowser; private DirectBindingEditor bindingEditor; + private String validatedSource, validatingSource; + private boolean validationPreview; + private java.util.List bindingProblems = + java.util.List.of(); private Tab directBindingTab; private long bindingRequest; private boolean interactiveRequested, interactiveRestart; @@ -182,7 +186,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { if (kit.editor().getFxomDocument() == null) return; var target = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(id); if (target != null) kit.editor().getSelection().select(target); - }); + }, + values -> validateBindings(values, false)); bindingBrowser = new BindingBrowser( this::discoverBindings, @@ -639,6 +644,9 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private void invalidateBindings(String message) { bindingRequest++; + validatedSource = null; + validatingSource = null; + validationPreview = false; bindingWorkflow.cancel(); bindingBrowser.invalidate(message); } @@ -682,10 +690,66 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { return false; } + private void validateBindings(DirectBindingDocument.Values values, boolean preview) { + withDraft( + () -> { + if (preview && draft.text().equals(validatingSource)) { + validationPreview = true; + return; + } + long ticket = ++bindingRequest; + var captured = draft; + validatingSource = captured.text(); + validationPreview |= preview; + status.setText("Validating binding types in target runtime…"); + bindingWorkflow + .bindingDetails( + last, + captured, + values == null ? "" : values.path(), + values == null ? "both" : values.direction(), + values == null ? "" : values.control(), + values == null ? "" : values.property()) + .whenComplete( + (metadata, error) -> + fx( + () -> { + if (closed + || ticket != bindingRequest + || draft.revision() != captured.revision()) return; + validatingSource = null; + boolean resumePreview = validationPreview; + validationPreview = false; + if (error != null) { + status.setText( + "Binding validation unavailable: " + error.getMessage()); + return; + } + validatedSource = captured.text(); + bindingProblems = metadata.problems(); + bindingEditor.metadata( + metadata, + values, + (last + ? "Explicit retained build (outdated)" + : "Current successful build") + + " · draft revision " + + captured.revision()); + status.setText( + "Binding validation complete: " + + metadata.problems().size() + + " diagnostics"); + if (resumePreview) render(); + })); + }); + } + private void discoverBindings(String path, String direction) { withDraft( () -> { long ticket = ++bindingRequest; + validatingSource = null; + validationPreview = false; bindingBrowser.invalidate("Discovering declared types in target runtime…"); var captured = draft; bindingWorkflow @@ -803,6 +867,19 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { withDraft( () -> { if (!bindingPreviewAllowed()) return; + if (!draft.text().equals(validatedSource)) { + validateBindings(null, true); + return; + } + if (bindingProblems.stream().anyMatch(p -> p.severity().equals("ERROR"))) { + status.setText( + "Preview blocked: " + + bindingProblems.stream() + .filter(p -> p.severity().equals("ERROR")) + .map(p -> p.message()) + .collect(java.util.stream.Collectors.joining("; "))); + return; + } var selected = examples.getValue(); if (selected == null || projection == null) return; long ticket = renderSequence.incrementAndGet(); diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java index 3d551d2..8431dc7 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DirectBindingEditor.java @@ -1,5 +1,6 @@ package de.neitzel.fxdesigner.kit; +import de.neitzel.fxdesigner.protocol.BindingMetadata; import java.util.function.*; import javafx.scene.control.*; import javafx.scene.layout.*; @@ -18,6 +19,8 @@ final class DirectBindingEditor extends VBox { property = field("property"), path = field("path"), converter = field("converter"); + private final ComboBox converters = new ComboBox<>(); + private final Button inspect = new Button("Find Converters / Validate"); private final ComboBox direction = new ComboBox<>(); private final CheckBox replace = new CheckBox("Replace conflicting bindings"); private final Label status = new Label("Open a valid FXML document to edit bindings."); @@ -29,9 +32,23 @@ final class DirectBindingEditor extends VBox { private boolean loading, writable, active, followingCanvas; private final Consumer navigate; - DirectBindingEditor(Consumer edit, Consumer navigate) { + DirectBindingEditor( + Consumer edit, + Consumer navigate, + Consumer discover) { this.navigate = navigate; setSpacing(5); + converters.setId("direct-converters"); + converters.setPrefWidth(680); + inspect.setOnAction(e -> discover.accept(values())); + converters.setOnAction( + e -> { + var c = converters.getValue(); + if (c != null) converter.setText(c.name()); + }); + for (var field : java.util.List.of(control, property, path)) + field.textProperty().addListener((o, a, b) -> converters.getItems().clear()); + direction.valueProperty().addListener((o, a, b) -> converters.getItems().clear()); bindings.setId("direct-bindings"); bindings.setPrefHeight(100); direction.setId("direct-direction"); @@ -73,7 +90,13 @@ final class DirectBindingEditor extends VBox { new Label("Direction"), direction); grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter); - getChildren().addAll(bindings, grid, new HBox(8, fresh, apply, remove, replace), status); + getChildren() + .addAll( + bindings, + grid, + new HBox(8, inspect, converters), + new HBox(8, fresh, apply, remove, replace), + status); buttons(); } @@ -120,6 +143,7 @@ final class DirectBindingEditor extends VBox { void load(String text, boolean editable) { if (source.equals(text) && writable == editable) return; + converters.getItems().clear(); source = text; writable = editable; loading = true; @@ -131,8 +155,8 @@ final class DirectBindingEditor extends VBox { bindings.getSelectionModel().clearSelection(); status.setText( doc.errors().isEmpty() - ? "Select a DirectBinding or choose New binding. Converter types are not yet" - + " verified." + ? "Select a DirectBinding or choose New binding. Use Find Converters / Validate to" + + " check this draft." : String.join("\n", doc.errors())); } catch (Exception error) { bindings.getItems().clear(); @@ -162,6 +186,35 @@ final class DirectBindingEditor extends VBox { }); } + void metadata( + BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) { + status.setText( + provenance + + "\n" + + (metadata.problems().isEmpty() + ? "No detected binding errors." + : metadata.problems().stream() + .map( + p -> + p.severity() + + " " + + p.control() + + "." + + p.property() + + ": " + + p.message()) + .collect(java.util.stream.Collectors.joining("\n")))); + if (requested == null || !values().equals(requested)) return; + converters.getItems().setAll(metadata.converters()); + var matches = + metadata.converters().stream().filter(c -> c.status().equals("COMPATIBLE")).toList(); + if (converter.getText().isBlank() && matches.size() == 1) { + converters.setValue(matches.getFirst()); + status.setText( + status.getText() + "\nUnique compatible Converter proposed; review and Apply binding."); + } + } + void error(String message) { status.setText(message); } diff --git a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java index 82f2f20..a108218 100644 --- a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java +++ b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java @@ -267,6 +267,88 @@ public final class ProjectionHostChecks { return doc.snapshot().toCompletableFuture().get().text(); } + private static boolean bindingStatus(DesignerPane pane, String text) throws Exception { + return fx(() -> ((Label) pane.lookup("#direct-status")).getText().contains(text)); + } + + private static void converterEdits(DesignerPane pane, DocumentSession document, String full) + throws Exception { + bindingTab(pane, "Direct bindings"); + selectDirect(pane, "rankInput"); + click(pane, "Find Converters / Validate"); + await(() -> fx(() -> !((ComboBox) pane.lookup("#direct-converters")).getItems().isEmpty())); + check( + fx( + () -> + ((TextField) pane.lookup("#direct-converter")) + .getText() + .equals("fixture.components.RankConverter")), + "Existing Converter changed"); + check(documentText(document).equals(full), "Validation altered source"); + directField(pane, "converter", ""); + click(pane, "Apply binding"); + await(() -> !documentText(document).equals(full)); + String invalid = documentText(document); + await(() -> fx(() -> source(pane).getText().equals(invalid))); + click(pane, "Render drafts"); + await(() -> bindingStatus(pane, "Incompatible value types")); + await( + () -> + fx( + () -> + pane.lookupAll(".label").stream() + .anyMatch( + n -> + n instanceof Label l + && l.getText() != null + && l.getText().startsWith("Preview blocked:")))); + selectDirect(pane, "rankInput"); + click(pane, "Find Converters / Validate"); + await( + () -> + fx( + () -> + ((TextField) pane.lookup("#direct-converter")) + .getText() + .equals("fixture.components.RankConverter"))); + check(documentText(document).equals(invalid), "Proposal applied without review"); + click(pane, "Apply binding"); + await(() -> !documentText(document).equals(invalid)); + String repaired = documentText(document); + click(pane, "Undo"); + await(() -> documentText(document).equals(invalid)); + click(pane, "Redo"); + await(() -> documentText(document).equals(repaired)); + await(() -> fx(() -> source(pane).getText().equals(repaired))); + selectDirect(pane, "rankInput"); + directField(pane, "converter", "fixture.components.LegacyRankConverter"); + click(pane, "Apply binding"); + await(() -> documentText(document).contains("LegacyRankConverter")); + await(() -> fx(() -> source(pane).getText().contains("LegacyRankConverter"))); + selectDirect(pane, "rankInput"); + click(pane, "Find Converters / Validate"); + await(() -> bindingStatus(pane, "WARNING rankInput.text")); + check( + fx( + () -> + ((TextField) pane.lookup("#direct-converter")) + .getText() + .equals("fixture.components.LegacyRankConverter")), + "Unverified explicit Converter replaced"); + set(document, full); + await(() -> fx(() -> source(pane).getText().equals(full))); + click(pane, "New binding"); + directField(pane, "control", "action"); + directField(pane, "property", "text"); + directField(pane, "path", "name"); + click(pane, "Find Converters / Validate"); + await(() -> fx(() -> ((ComboBox) pane.lookup("#direct-converters")).getItems().size() > 1)); + check( + fx(() -> ((TextField) pane.lookup("#direct-converter")).getText().isEmpty()), + "Ambiguous candidates silently selected"); + check(documentText(document).equals(full), "Converter browsing altered source"); + } + private static void directBindingEdits(DesignerPane pane, DocumentSession document, String full) throws Exception { bindingTab(pane, "Direct bindings"); @@ -697,6 +779,7 @@ public final class ProjectionHostChecks { && ((TextField) pane.lookup("#direct-path")).getText().equals("rank")), "Discovery did not populate binding form"); check(documentText(document).equals(full), "Discovery handoff changed source before Apply"); + converterEdits(pane, document, full); directBindingEdits(pane, document, full); document .save(document.snapshot().toCompletableFuture().get().revision()) diff --git a/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ConverterCapabilities.java b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ConverterCapabilities.java new file mode 100644 index 0000000..99ba969 --- /dev/null +++ b/designer-preview-api/src/main/java/de/neitzel/fxdesigner/preview/ConverterCapabilities.java @@ -0,0 +1,15 @@ +package de.neitzel.fxdesigner.preview; + +import java.lang.annotation.*; + +/** Optional design-time contract. Declare on the concrete Converter; never inherited. */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ConverterCapabilities { + Class modelType(); + + Class controlType(); + + /** Whether toAttributeValue is implemented. Forward conversion is always required. */ + boolean reverse() default false; +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java index 057fee6..9647897 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java @@ -15,9 +15,14 @@ public final class BindingDiscovery { private static final String FX = "http://javafx.com/fxml/1"; private final ClassLoader loader; private final List diagnostics = new ArrayList<>(); + private boolean incompleteModel; private final Map> paths = new TreeMap<>(); private final List attributes = new ArrayList<>(); + private record TargetType(Class type, boolean writable) {} + + private final Map targetTypes = new HashMap<>(); + private BindingDiscovery(ClassLoader loader) { this.loader = loader; } @@ -26,7 +31,20 @@ public final class BindingDiscovery { 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); + return inspect(fxml, path, direction, "", "", loader); + } + + public static BindingMetadata inspect( + String fxml, + String path, + String direction, + String control, + String property, + 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, control, property); } private static Document parse(String xml) throws Exception { @@ -63,6 +81,7 @@ public final class BindingDiscovery { for (Class c = type; c != null && c != Object.class; c = c.getSuperclass()) { try { load(c.getName() + "BeanInfo"); + incompleteModel = true; diagnostics.add("Custom BeanInfo requires generated metadata: " + c.getName()); return List.of(); } catch (ClassNotFoundException absent) { @@ -104,6 +123,7 @@ public final class BindingDiscovery { private void attributes(Class type, String prefix, Set> ancestors, int depth) throws Exception { if (depth > 4 || ancestors.contains(type)) { + incompleteModel = true; diagnostics.add("Nested path expansion stopped at " + prefix); return; } @@ -137,7 +157,13 @@ public final class BindingDiscovery { } } - private BindingMetadata inspect(String xml, String selected, String direction) throws Exception { + private BindingMetadata inspect( + String xml, + String selected, + String direction, + String selectedControl, + String selectedProperty) + throws Exception { Document doc = parse(xml); String model = model(doc); if (model.isEmpty()) @@ -149,7 +175,11 @@ public final class BindingDiscovery { List.of(), List.of( "Declare this View's Model with before discovering" - + " bindings.")); + + " bindings."), + List.of(), + List.of( + new Problem( + "", "", "WARNING", "No Model declaration; semantic validation unavailable"))); attributes(load(model), "", Set.of(), 0); if (selected.isEmpty()) selected = @@ -196,6 +226,9 @@ public final class BindingDiscovery { value = load(componentModel); constrained = true; } + targetTypes.put( + id + "." + property, + new TargetType(value, Property.class.isAssignableFrom(m.getReturnType()))); String status, reason; if (!Property.class.isAssignableFrom(m.getReturnType())) { status = "INCOMPATIBLE"; @@ -245,7 +278,129 @@ public final class BindingDiscovery { .indexOf(t.status())) .thenComparing(Target::control) .thenComparing(Target::property)); - return new BindingMetadata(model, selected, direction, attributes, targets, diagnostics); + var converters = new ConverterDiscovery(loader); + var chosen = targetTypes.get(selectedControl + "." + selectedProperty); + var candidates = + bypassesConverter(selected) + ? List.of() + : converters.candidates(source, chosen == null ? null : chosen.type(), direction); + return new BindingMetadata( + model, + selected, + direction, + attributes, + targets, + diagnostics, + candidates, + validate(doc, imports, ids, converters)); + } + + private boolean bypassesConverter(String path) { + return attributes.stream() + .anyMatch(a -> a.path().equals(path) && Set.of("COMPLEX", "COLLECTION").contains(a.kind())); + } + + private List validate( + Document doc, List imports, Set ids, ConverterDiscovery converters) + throws Exception { + var problems = new ArrayList(); + var occupied = new HashSet(); + 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; + Node parent = e.getParentNode(); + if (parent instanceof Element p && p.getTagName().equals("elements")) + parent = p.getParentNode(); + if (!(parent instanceof Element list) + || !library(list, "BindingList", imports) + || !(list.getParentNode() instanceof Element define) + || !FX.equals(define.getNamespaceURI()) + || !"define".equals(define.getLocalName()) + || define.getParentNode() != doc.getDocumentElement()) continue; + String control = e.getAttribute("control"), + property = e.getAttribute("property"), + key = control + "." + property; + if (!occupied.add(key)) + problems.add(new Problem(control, property, "ERROR", "Duplicate binding target")); + if (library(e, "ComputedBinding", imports)) continue; + 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(control, property, "WARNING", "Nested declaration requires Source review")); + continue; + } + String path = e.getAttribute("path"), + direction = e.getAttribute("direction"), + converter = e.getAttribute("converter"); + if (!converter.isEmpty() && bypassesConverter(path)) { + problems.add( + new Problem( + control, + property, + "WARNING", + "Runtime bypasses Converters for complex and collection Attributes")); + converter = ""; + } + String severity = "", message = ""; + Class source = paths.get(path); + TargetType target = targetTypes.get(key); + if (!Set.of("view", "model", "both").contains(direction)) { + severity = "ERROR"; + message = "Invalid direction"; + } else if (!ids.contains(control)) { + severity = "ERROR"; + message = "Unknown control: " + control; + } else if (target == null) { + severity = + diagnostics.stream().anyMatch(d -> d.startsWith(control + ":")) ? "WARNING" : "ERROR"; + message = "No discoverable JavaFX property: " + key; + } else if (!target.writable()) { + severity = "ERROR"; + message = "Runtime requires a writable JavaFX Property"; + } else if (source == null) { + severity = incompleteModel ? "WARNING" : "ERROR"; + message = "Unknown or unresolved Model path: " + path; + } else if (!converter.isEmpty()) { + var c = converters.check(converter, source, target.type(), direction); + if (!c.status().equals("COMPATIBLE")) { + severity = c.status().equals("UNVERIFIED") ? "WARNING" : "ERROR"; + message = converter + ": " + c.reason(); + } + } else if (target.type() == null + || source == Object.class + || target.type() == Object.class + || Collection.class.isAssignableFrom(source) + || Collection.class.isAssignableFrom(target.type()) + || Map.class.isAssignableFrom(source) + || Map.class.isAssignableFrom(target.type())) { + severity = "WARNING"; + message = "Value types require additional metadata"; + } else { + Class a = box(source), b = box(target.type()); + if ((!direction.equals("model") && !b.isAssignableFrom(a)) + || (!direction.equals("view") && !a.isAssignableFrom(b))) { + severity = "ERROR"; + message = "Incompatible value types; select a compatible Converter"; + } + } + if (!severity.isEmpty()) problems.add(new Problem(control, property, severity, message)); + } + return problems; + } + + private boolean library(Element e, String simple, List imports) { + String name = "de.neitzel.fx.view." + simple; + if (e.getTagName().equals(name)) return true; + if (!e.getTagName().equals(simple)) return false; + try { + return resolve(e, imports).getName().equals(name); + } catch (Exception | LinkageError ignored) { + return false; + } } private static boolean defined(Element e) { @@ -310,7 +465,7 @@ public final class BindingDiscovery { return null; } - private static Class box(Class c) { + static Class box(Class c) { if (!c.isPrimitive()) return c; return Map., Class>of( int.class, diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ConverterDiscovery.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ConverterDiscovery.java new file mode 100644 index 0000000..12597a6 --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/ConverterDiscovery.java @@ -0,0 +1,138 @@ +package de.neitzel.fxdesigner.target; + +import de.neitzel.fx.view.Converter; +import de.neitzel.fxdesigner.preview.ConverterCapabilities; +import de.neitzel.fxdesigner.protocol.BindingMetadata; +import java.io.*; +import java.lang.reflect.Modifier; +import java.nio.file.*; +import java.util.*; +import java.util.jar.JarFile; + +/** Reads class headers before loading only Converter descendants, always without initialization. */ +final class ConverterDiscovery { + private final ClassLoader loader; + private int indexed; + private final SortedSet names = new TreeSet<>(); + private final Map> hierarchy = new HashMap<>(); + + ConverterDiscovery(ClassLoader loader) throws IOException { + this.loader = loader; + var entries = new LinkedHashSet(); + for (String key : List.of("java.class.path", "jdk.module.path")) + entries.addAll(Arrays.asList(System.getProperty(key, "").split(File.pathSeparator))); + 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(".class")).iterator(); + while (it.hasNext()) + try (var in = Files.newInputStream(it.next())) { + header(in); + } + } + } 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(".class")).iterator(); + while (it.hasNext()) + try (var in = jar.getInputStream(it.next())) { + header(in); + } + } + } + } + for (String name : hierarchy.keySet()) + if (descendant(name, new HashSet<>()) && !name.equals(Converter.class.getName())) + names.add(name); + if (names.size() > 256) throw new IOException("Too many Converter candidates"); + } + + private boolean descendant(String name, Set visiting) { + if (name.equals(Converter.class.getName())) return true; + if (!visiting.add(name)) return false; + return hierarchy.getOrDefault(name, List.of()).stream().anyMatch(p -> descendant(p, visiting)); + } + + private void header(InputStream stream) throws IOException { + if (++indexed > 100000) throw new IOException("Converter class index exceeds limit"); + var in = new DataInputStream(new BufferedInputStream(stream)); + if (in.readInt() != 0xcafebabe) throw new IOException("Invalid class header"); + in.readUnsignedShort(); + in.readUnsignedShort(); + Object[] cp = new Object[in.readUnsignedShort()]; + for (int i = 1; i < cp.length; i++) + switch (in.readUnsignedByte()) { + case 1 -> cp[i] = in.readUTF(); + case 7 -> cp[i] = in.readUnsignedShort(); + case 3, 4, 9, 10, 11, 12, 17, 18 -> in.skipNBytes(4); + case 5, 6 -> { + in.skipNBytes(8); + i++; + } + case 8, 16, 19, 20 -> in.skipNBytes(2); + case 15 -> in.skipNBytes(3); + default -> throw new IOException("Unsupported constant pool entry"); + } + in.readUnsignedShort(); + String name = name(cp, in.readUnsignedShort()); + int parent = in.readUnsignedShort(); + var parents = new ArrayList(); + if (parent != 0) parents.add(name(cp, parent)); + int n = in.readUnsignedShort(); + for (int i = 0; i < n; i++) parents.add(name(cp, in.readUnsignedShort())); + hierarchy.putIfAbsent(name, parents); + } + + private static String name(Object[] cp, int index) { + return ((String) cp[(Integer) cp[index]]).replace('/', '.'); + } + + List candidates(Class model, Class control, String direction) { + return names.stream().map(n -> check(n, model, control, direction)).toList(); + } + + BindingMetadata.Converter check(String name, Class model, Class control, String direction) { + try { + Class type = Class.forName(name, false, loader); + if (!Converter.class.isAssignableFrom(type) + || type.isInterface() + || Modifier.isAbstract(type.getModifiers()) + || !Modifier.isPublic(type.getModifiers())) + return result(name, "ERROR", "Converter must be a public concrete implementation"); + var meta = type.getDeclaredAnnotation(ConverterCapabilities.class); + if (meta == null) + return result(name, "UNVERIFIED", "No capabilities declared on this concrete class"); + if (meta.reverse() + && type.getMethod("toAttributeValue", Object.class).getDeclaringClass() + == Converter.class) + return result( + name, + "ERROR", + "Reverse conversion declared but default throwing implementation inherited"); + if (meta.modelType() == void.class || meta.controlType() == void.class) + return result(name, "ERROR", "Converter value types cannot be void"); + if (!direction.equals("view") && !meta.reverse()) + return result(name, "INCOMPATIBLE", "This direction requires reverse conversion"); + if (model == null || control == null || model == Object.class || control == Object.class) + return result(name, "UNVERIFIED", "Binding value types are unresolved"); + Class m = BindingDiscovery.box(model), + c = BindingDiscovery.box(control), + a = BindingDiscovery.box(meta.modelType()), + b = BindingDiscovery.box(meta.controlType()); + // Forward conversion is also used for initial value in model-direction bindings. + boolean ok = a.isAssignableFrom(m) && c.isAssignableFrom(b); + if (!direction.equals("view")) ok &= b.isAssignableFrom(c) && m.isAssignableFrom(a); + return result( + name, + ok ? "COMPATIBLE" : "INCOMPATIBLE", + ok ? "Declared types and direction match" : "Declared types do not support this binding"); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + return result(name, "ERROR", "Converter metadata unavailable: " + e.getMessage()); + } + } + + private static BindingMetadata.Converter result(String n, String s, String r) { + return new BindingMetadata.Converter(n, s, r); + } +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java index f4c81f4..c2e92d8 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java @@ -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-v1"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v2"), Set.of())); var welcome = FrameCodec.read(socket.getInputStream()); if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1) @@ -115,11 +115,16 @@ public final class WorkerMain { var loader = WorkerMain.class.getClassLoader(); if (p.operation() == Operation.BINDINGS) { var input = PreviewMessages.decode(p.payload()); - if (!input.keySet().equals(Set.of("fxml", "path", "direction"))) + if (!input.keySet().equals(Set.of("fxml", "path", "direction", "control", "property"))) throw new IOException("Invalid binding discovery input"); var metadata = BindingDiscovery.inspect( - input.get("fxml"), input.get("path"), input.get("direction"), loader); + input.get("fxml"), + input.get("path"), + input.get("direction"), + input.get("control"), + input.get("property"), + loader); send( socket, new Packet( diff --git a/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ConverterDiscoveryTest.java b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ConverterDiscoveryTest.java new file mode 100644 index 0000000..949245b --- /dev/null +++ b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/ConverterDiscoveryTest.java @@ -0,0 +1,148 @@ +package de.neitzel.fxdesigner.target; + +import static org.junit.jupiter.api.Assertions.*; + +import de.neitzel.fx.view.Converter; +import de.neitzel.fxdesigner.preview.ConverterCapabilities; +import java.util.*; +import org.junit.jupiter.api.Test; + +class ConverterDiscoveryTest { + @ConverterCapabilities(modelType = Integer.class, controlType = String.class, reverse = true) + public static class Valid implements Converter { + static { + System.setProperty("converter.initialized", "yes"); + } + + public Valid() { + throw new AssertionError("constructed"); + } + + public Object toControlValue(Object value) { + throw new AssertionError("invoked"); + } + + public Object toAttributeValue(Object value) { + throw new AssertionError("invoked"); + } + } + + public static class Legacy extends Valid {} + + @ConverterCapabilities(modelType = Integer.class, controlType = String.class, reverse = true) + public static class Broken implements Converter { + public Object toControlValue(Object v) { + return null; + } + } + + @ConverterCapabilities(modelType = Integer.class, controlType = String.class) + public static class Forward implements Converter { + public Object toControlValue(Object v) { + return null; + } + } + + private ConverterDiscovery discovery() throws Exception { + return new ConverterDiscovery(getClass().getClassLoader()); + } + + @Test + void discoveryNeverInitializesOrConstructsAndDoesNotInheritCapabilities() throws Exception { + var d = discovery(); + assertTrue( + d.candidates(int.class, String.class, "both").stream() + .anyMatch( + c -> c.name().equals(Valid.class.getName()) && c.status().equals("COMPATIBLE"))); + assertEquals( + "UNVERIFIED", d.check(Legacy.class.getName(), int.class, String.class, "both").status()); + assertNull(System.getProperty("converter.initialized")); + } + + @Test + void reverseContractAndConcreteTypeAreChecked() throws Exception { + var d = discovery(); + assertEquals( + "ERROR", d.check(Broken.class.getName(), int.class, String.class, "view").status()); + assertEquals( + "ERROR", d.check(String.class.getName(), int.class, String.class, "both").status()); + assertEquals("ERROR", d.check("missing.Converter", int.class, String.class, "both").status()); + assertEquals( + "INCOMPATIBLE", + d.check(Forward.class.getName(), int.class, String.class, "model").status()); + assertEquals( + "COMPATIBLE", d.check(Forward.class.getName(), int.class, String.class, "view").status()); + } + + @Test + void forwardInitializationIsRequiredEvenForModelDirection() throws Exception { + var d = discovery(); + assertEquals( + "INCOMPATIBLE", + d.check(Valid.class.getName(), Number.class, String.class, "model").status()); + assertEquals( + "COMPATIBLE", + d.check(Valid.class.getName(), int.class, CharSequence.class, "view").status()); + assertEquals( + "INCOMPATIBLE", + d.check(Valid.class.getName(), int.class, CharSequence.class, "both").status()); + } + + private String xml(String path, String property, String converter) { + return "" + + ""; + } + + private de.neitzel.fxdesigner.protocol.BindingMetadata inspect( + String path, String property, String converter) throws Exception { + return BindingDiscovery.inspect( + xml(path, property, converter), + "rank", + "both", + "input", + "text", + getClass().getClassLoader()); + } + + @Test + void validatesPathsPropertiesAndConverterContracts() throws Exception { + assertTrue(inspect("rank", "text", Valid.class.getName()).problems().isEmpty()); + assertTrue( + BindingDiscovery.inspect( + xml("absent", "text", ""), + "absent", + "both", + "input", + "text", + getClass().getClassLoader()) + .problems() + .stream() + .anyMatch(p -> p.severity().equals("ERROR"))); + for (var input : + List.of( + new String[] {"absent", "text", ""}, + new String[] {"rank", "absent", ""}, + new String[] {"rank", "width", ""}, + new String[] {"rank", "text", ""}, + new String[] {"rank", "text", Broken.class.getName()})) + assertTrue( + inspect(input[0], input[1], input[2]).problems().stream() + .anyMatch(p -> p.severity().equals("ERROR")), + Arrays.toString(input)); + assertEquals( + "WARNING", + inspect("rank", "text", Legacy.class.getName()).problems().getFirst().severity()); + assertNull(System.getProperty("converter.initialized")); + } +} diff --git a/docs/binding-discovery.md b/docs/binding-discovery.md index 855d8aa..94b2dab 100644 --- a/docs/binding-discovery.md +++ b/docs/binding-discovery.md @@ -1,6 +1,6 @@ # Shared Model and binding-target discovery -This is the first stage-4 implementation slice: a read-only binding browser shared by standalone and IntelliJ. The [DirectBinding inspector](direct-binding-inspector.md) now uses discovered targets to start editable declarations. Converter discovery, generated metadata and the complete inspector remain subsequent work. +This is the first stage-4 implementation slice: a read-only binding browser shared by standalone and IntelliJ. The [DirectBinding inspector](direct-binding-inspector.md) now uses discovered targets to start editable declarations. [Converter discovery and DirectBinding validation](converter-validation.md) now extend this slice; generated metadata and the complete inspector remain subsequent work. ## Use @@ -12,13 +12,13 @@ For Person the browser exposes `name`, `rank`, `home`, `home.street`, and `visit ## Metadata boundary -The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v1` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged. +The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v2` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged. Model fallback follows NeitzelLib's public read/write JavaBean Attribute rules and simple/complex/collection classification. Nested paths expand to depth four with cycle diagnostics; collection paths expose declared element types without pretending a collection is a single nested Model. Explicit custom BeanInfo is not executed: types requiring it report a generated-metadata diagnostic. This slice does not yet expose custom/replacement ViewModel members, generated metadata, Computed methods or library-provided message properties. Its compatibility results describe the Model-Attribute binding case, not the complete custom ViewModel API. Targets come from public instance JavaFX observable-property accessors. The runtime requires writable `Property` accessors, so read-only targets remain visible as incompatible. Generic observable signatures and getter types identify the value type; incomplete or contradictory signatures remain unresolved. Component `model` targets use the Component resource's explicit Model declaration instead of treating Object as a specific type. Component resource metadata comes from the selected build; current-document metadata comes from the captured draft. -Compatibility uses boxed declared types and target-runtime assignability: `view` tests Model-to-control, `model` the reverse, and `both` requires both. No implicit numeric/string conversion is assumed. Type mismatches require a compatible Converter; Converter availability/signatures are not yet discovered. Unknown Object types and collection variance remain unresolved, rather than being advertised as verified bindings. Results are potential type matches, not a check that a target is currently unbound or that all runtime access/injection conditions hold. +Compatibility uses boxed declared types and target-runtime assignability: `view` tests Model-to-control, `model` the reverse, and `both` requires both. No implicit numeric/string conversion is assumed. Type mismatches require a compatible Converter; Converter availability and optional capabilities are checked by the DirectBinding inspector. Unknown Object types and collection variance remain unresolved, rather than being advertised as verified bindings. Results are potential type matches, not a check that a target is currently unbound or that all runtime access/injection conditions hold. ## Freshness and limits diff --git a/docs/converter-validation.md b/docs/converter-validation.md new file mode 100644 index 0000000..de0c4a6 --- /dev/null +++ b/docs/converter-validation.md @@ -0,0 +1,38 @@ +# Converter discovery and binding validation + +Both hosts use the same **Bindings → Direct bindings → Find Converters / Validate** action. It checks the current document and lists Converter candidates for the form's Model path, target property and direction. Choose a candidate or keep an explicit class name, review the form, then **Apply binding**. Discovery and validation never edit FXML. Apply uses the existing source-preserving command and host Undo/Redo. + +A single compatible candidate is proposed only when the Converter field is empty. Multiple matches require a choice. Explicit selections, including unannotated classes, are retained. Form changes reject obsolete suggestions; document changes and builds invalidate validation. Results identify the draft revision and explicitly retained builds. Validation uses a separate target worker and leaves existing interactive sessions alone. New preview requests validate the current parent document before launching; known errors block the request, while warnings remain visible and saving stays available. + +## Optional capabilities + +The application-facing `designer-preview-api` artifact supplies a runtime-retained annotation with no JavaFX, Kit or IntelliJ dependency: + +```java +@ConverterCapabilities(modelType = Integer.class, + controlType = String.class, + reverse = true) +public class RankConverter implements de.neitzel.fx.view.Converter { + // Implement toControlValue and toAttributeValue. +} +``` + +Import `de.neitzel.fxdesigner.preview.ConverterCapabilities`. Named modules using this annotation require `de.neitzel.fxdesigner.preview`. The annotation is tooling metadata; NeitzelLib's Converter interface and runtime behavior remain unchanged. No new NeitzelLib artifact is required. + +Metadata must be declared on the concrete class. An unannotated subclass does not inherit verified capabilities. Forward conversion is always required, including initialization of a converted `model` binding. `model` and `both` additionally require reverse conversion. Declaring reverse support while inheriting the interface's default throwing method is an error. Declared input/output types are checked with boxing and target-runtime assignability in each required direction. Plain bindings retain NeitzelLib's separate direction rules. NeitzelLib bypasses Converters for complex and collection Attributes; those paths get no proposals, and an explicit Converter produces a warning rather than a false compatibility claim. + +The chooser distinguishes compatible, incompatible, unverified and invalid candidates. Metadata is a declared contract, not a proof of implementation behavior. Unannotated Converters remain usable with a warning. Target previews still exercise conversion, and the real fixtures test conversion failure/value preservation and recovery through NeitzelLib's Message behavior. + +## Worker and validation boundary + +The worker indexes class headers in captured classpath/module-path directories and JARs, respecting multi-release JAR selection, then loads Converter descendants without initialization. It reads annotations and method signatures without constructing Converters or invoking application methods. The index is bounded to 100,000 classes and 256 candidates; the normal worker deadline and cancellation apply. Application classes never enter a host. + +`bindings-v2` negotiates metadata format 2, adding candidate and document-problem records to the existing BINDINGS operation without changing its wire ordinal. Older binding capabilities are not silently interpreted as this format. The UTF-8 payload, metadata counts, request identity, build identity and document-revision checks remain bounded. + +The validator covers attribute-form DirectBindings in root BindingLists: discoverable Model paths, control IDs, JavaFX property existence/writability, plain type compatibility, explicit Converter availability/capabilities and duplicate Direct/Computed targets. Known violations are errors. Unresolved types, incomplete/custom Model metadata, missing Model declarations and nested binding syntax produce warnings. Computed methods, custom/generated ViewModels, collection semantics, nested Component draft metadata, dependency injection availability and runtime method behavior are not fully validated in this slice. A warning does not certify that preview will succeed. Complete Problems-view integration and broader binding authoring remain later work. + +## Automated qualification + +Unit tests cover non-initialization, inherited/unannotated metadata, reverse-method inconsistencies, assignability, the special converted model-direction initialization rule, path/property errors and metadata round trips. Real classpath and named-module fixtures exercise discovery and validate the same application declarations without initializing Converters. Shared live-UI checks cover explicit preservation, a unique proposal requiring Apply, ambiguous matches requiring selection, unverified warnings, type-error preview blocking and source-preserving Undo/Redo in standalone and registered IntelliJ. Existing rendering, input and worker lifecycle checks remain enabled. No manual review is required for this slice; earlier skipped manual checks retain their recorded disposition. + +Qualification on 2026-11-13 passed: 79 Maven tests and 7 IntelliJ adapter tests; 34 target processes with 438 fixture assertions; 48 worker lifecycle cycles; connected workflows and live shared UI in standalone and registered IntelliJ for classpath and module-path applications. Native isolation, worker shutdown, bytecode/package boundaries and documentation checks passed. Final host logs contain no uncaught exceptions and no new native crash report appeared. The live regression exposed and fixed an automatic canvas-refresh race that replaced an in-flight chooser request; pending validation now also satisfies that preview request. Local evidence: `/tmp/converter-bootstrap.log`, `/tmp/converter-registered-ide.log`, and `/tmp/converter-protocol-test.log` (including rejection of the old binding capability). diff --git a/docs/direct-binding-inspector.md b/docs/direct-binding-inspector.md index 07453c4..7c2562b 100644 --- a/docs/direct-binding-inspector.md +++ b/docs/direct-binding-inspector.md @@ -14,9 +14,9 @@ A target occupied by another DirectBinding or ComputedBinding requires **Replace ## Validation boundary -Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. These known errors block a new canvas/interactive preview request while allowing save and source repair. Type compatibility remains the separate discovery result; the editor does not yet prove path existence, Converter compatibility or runtime property availability. A syntactically valid but incompatible declaration can therefore still fail in the target runtime. +Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. [Converter discovery and binding validation](converter-validation.md) now add target-side path/property/type and capability checks, a shared chooser, and automatic validation before new previews. Saving and source repair remain available on errors. -Converter names are preserved/editable text, not a discovered or verified Converter chooser. Generated/custom ViewModel metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work. +Generated/custom ViewModel metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work. ## Automated qualification diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md index 78059ca..87a5e24 100644 --- a/docs/repository-bootstrap-plan.md +++ b/docs/repository-bootstrap-plan.md @@ -105,3 +105,7 @@ Manual review disposition (2026-11-13): the user accepted proceeding after the r ## Shared discovery: first stage-4 slice Implemented the [read-only binding browser](binding-discovery.md) in the shared Kit UI: target-side Person Attribute/path and collection-element discovery, Component Model declarations and direction-aware binding-target compatibility. Real classpath and module-path fixtures qualify both hosts. The [first editable DirectBinding inspector](direct-binding-inspector.md) adds creation, changing, removal and explicit conflict replacement through one source-preserving host undo command per operation. Remaining metadata categories and broader binding authoring retain their subsequent work packages. + +## Converter discovery and binding validation + +The [next shared stage-4 slice](converter-validation.md) adds optional concrete-class capabilities, non-initializing classpath/module-path discovery, reviewed Converter proposals and DirectBinding diagnostics with preview gating in both hosts. Full metadata, structural reference repair and Problems integration remain open. diff --git a/integration-fixtures/preview/classpath/app/pom.xml b/integration-fixtures/preview/classpath/app/pom.xml index 6e98132..69ec356 100644 --- a/integration-fixtures/preview/classpath/app/pom.xml +++ b/integration-fixtures/preview/classpath/app/pom.xml @@ -8,6 +8,11 @@ app-classpath + + de.neitzel.fxdesigner + designer-preview-api + 0.1.0-SNAPSHOT + de.neitzel.lib fx diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BrokenReverseConverter.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BrokenReverseConverter.java new file mode 100644 index 0000000..53e3559 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/BrokenReverseConverter.java @@ -0,0 +1,19 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = Integer.class, + controlType = String.class, + reverse = true) +public class BrokenReverseConverter implements de.neitzel.fx.view.Converter { + static { + System.setProperty("fixture.converter.initialized", "BrokenReverseConverter"); + } + + public BrokenReverseConverter() { + throw new AssertionError("Discovery must not construct Converters"); + } + + public Object toControlValue(Object value) { + return ""; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ForwardRankConverter.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ForwardRankConverter.java new file mode 100644 index 0000000..9058954 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/ForwardRankConverter.java @@ -0,0 +1,18 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = Integer.class, + controlType = String.class) +public class ForwardRankConverter implements de.neitzel.fx.view.Converter { + static { + System.setProperty("fixture.converter.initialized", "ForwardRankConverter"); + } + + public ForwardRankConverter() { + throw new AssertionError("Discovery must not construct Converters"); + } + + public Object toControlValue(Object value) { + return ""; + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/LegacyRankConverter.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/LegacyRankConverter.java new file mode 100644 index 0000000..9a002dc --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/LegacyRankConverter.java @@ -0,0 +1,11 @@ +package fixture.components; + +public class LegacyRankConverter extends RankConverter { + static { + System.setProperty("fixture.converter.initialized", "LegacyRankConverter"); + } + + public LegacyRankConverter() { + throw new AssertionError("Discovery must not construct Converters"); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NameConverterA.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NameConverterA.java new file mode 100644 index 0000000..9e3bb7a --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NameConverterA.java @@ -0,0 +1,15 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = String.class, + controlType = String.class, + reverse = true) +public class NameConverterA extends RankConverter { + static { + System.setProperty("fixture.converter.initialized", "NameConverterA"); + } + + public NameConverterA() { + throw new AssertionError("Discovery must not construct Converters"); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NameConverterB.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NameConverterB.java new file mode 100644 index 0000000..af105c4 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NameConverterB.java @@ -0,0 +1,15 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = String.class, + controlType = String.class, + reverse = true) +public class NameConverterB extends RankConverter { + static { + System.setProperty("fixture.converter.initialized", "NameConverterB"); + } + + public NameConverterB() { + throw new AssertionError("Discovery must not construct Converters"); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/RankConverter.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/RankConverter.java index 91ed6fd..3a76a34 100644 --- a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/RankConverter.java +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/RankConverter.java @@ -1,5 +1,9 @@ package fixture.components; +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = Integer.class, + controlType = String.class, + reverse = true) @jakarta.inject.Singleton public class RankConverter implements de.neitzel.fx.view.Converter { public Object toControlValue(Object value) { diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java index 74b822e..4bee914 100644 --- a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java @@ -90,6 +90,45 @@ public final class PreviewChecks { check( System.getProperty("fixture.provider.initialized") == null, "Discovery initialized provider"); + try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) { + String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + var details = BindingDiscovery.inspect(full, "rank", "both", "rankInput", "text", loader); + check( + details.problems().isEmpty(), + "Valid converter binding rejected: " + details.problems()); + for (var expected : + java.util.Map.of( + "RankConverter", + "COMPATIBLE", + "LegacyRankConverter", + "UNVERIFIED", + "BrokenReverseConverter", + "ERROR", + "ForwardRankConverter", + "INCOMPATIBLE") + .entrySet()) + check( + details.converters().stream() + .anyMatch( + c -> + c.name().equals("fixture.components." + expected.getKey()) + && c.status().equals(expected.getValue())), + "Converter status: " + expected); + var invalid = + BindingDiscovery.inspect( + full.replace( + "converter=\"fixture.components.RankConverter\"", + "converter=\"fixture.components.BrokenReverseConverter\""), + "rank", + "both", + loader); + check( + invalid.problems().stream().anyMatch(v -> v.severity().equals("ERROR")), + "Invalid reverse metadata accepted"); + check( + System.getProperty("fixture.converter.initialized") == null, + "Discovery initialized Converter"); + } System.out.println("PASS bindings " + checks); return; } diff --git a/integration-fixtures/preview/modulepath/app/pom.xml b/integration-fixtures/preview/modulepath/app/pom.xml index 81af602..f74ca10 100644 --- a/integration-fixtures/preview/modulepath/app/pom.xml +++ b/integration-fixtures/preview/modulepath/app/pom.xml @@ -8,6 +8,11 @@ app-modulepath + + de.neitzel.fxdesigner + designer-preview-api + 0.1.0-SNAPSHOT + de.neitzel.lib fx diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BrokenReverseConverter.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BrokenReverseConverter.java new file mode 100644 index 0000000..53e3559 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/BrokenReverseConverter.java @@ -0,0 +1,19 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = Integer.class, + controlType = String.class, + reverse = true) +public class BrokenReverseConverter implements de.neitzel.fx.view.Converter { + static { + System.setProperty("fixture.converter.initialized", "BrokenReverseConverter"); + } + + public BrokenReverseConverter() { + throw new AssertionError("Discovery must not construct Converters"); + } + + public Object toControlValue(Object value) { + return ""; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ForwardRankConverter.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ForwardRankConverter.java new file mode 100644 index 0000000..9058954 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/ForwardRankConverter.java @@ -0,0 +1,18 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = Integer.class, + controlType = String.class) +public class ForwardRankConverter implements de.neitzel.fx.view.Converter { + static { + System.setProperty("fixture.converter.initialized", "ForwardRankConverter"); + } + + public ForwardRankConverter() { + throw new AssertionError("Discovery must not construct Converters"); + } + + public Object toControlValue(Object value) { + return ""; + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/LegacyRankConverter.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/LegacyRankConverter.java new file mode 100644 index 0000000..9a002dc --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/LegacyRankConverter.java @@ -0,0 +1,11 @@ +package fixture.components; + +public class LegacyRankConverter extends RankConverter { + static { + System.setProperty("fixture.converter.initialized", "LegacyRankConverter"); + } + + public LegacyRankConverter() { + throw new AssertionError("Discovery must not construct Converters"); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/NameConverterA.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/NameConverterA.java new file mode 100644 index 0000000..9e3bb7a --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/NameConverterA.java @@ -0,0 +1,15 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = String.class, + controlType = String.class, + reverse = true) +public class NameConverterA extends RankConverter { + static { + System.setProperty("fixture.converter.initialized", "NameConverterA"); + } + + public NameConverterA() { + throw new AssertionError("Discovery must not construct Converters"); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/NameConverterB.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/NameConverterB.java new file mode 100644 index 0000000..af105c4 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/NameConverterB.java @@ -0,0 +1,15 @@ +package fixture.components; + +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = String.class, + controlType = String.class, + reverse = true) +public class NameConverterB extends RankConverter { + static { + System.setProperty("fixture.converter.initialized", "NameConverterB"); + } + + public NameConverterB() { + throw new AssertionError("Discovery must not construct Converters"); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/RankConverter.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/RankConverter.java index 91ed6fd..3a76a34 100644 --- a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/RankConverter.java +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/RankConverter.java @@ -1,5 +1,9 @@ package fixture.components; +@de.neitzel.fxdesigner.preview.ConverterCapabilities( + modelType = Integer.class, + controlType = String.class, + reverse = true) @jakarta.inject.Singleton public class RankConverter implements de.neitzel.fx.view.Converter { public Object toControlValue(Object value) { diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/module-info.java b/integration-fixtures/preview/modulepath/app/src/main/java/module-info.java index fb29ed7..2e3f552 100644 --- a/integration-fixtures/preview/modulepath/app/src/main/java/module-info.java +++ b/integration-fixtures/preview/modulepath/app/src/main/java/module-info.java @@ -1,5 +1,6 @@ module fixture.app { requires fx; + requires de.neitzel.fxdesigner.preview; requires injection; requires jakarta.inject; requires javafx.controls; diff --git a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java index 74b822e..4bee914 100644 --- a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java +++ b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java @@ -90,6 +90,45 @@ public final class PreviewChecks { check( System.getProperty("fixture.provider.initialized") == null, "Discovery initialized provider"); + try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) { + String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + var details = BindingDiscovery.inspect(full, "rank", "both", "rankInput", "text", loader); + check( + details.problems().isEmpty(), + "Valid converter binding rejected: " + details.problems()); + for (var expected : + java.util.Map.of( + "RankConverter", + "COMPATIBLE", + "LegacyRankConverter", + "UNVERIFIED", + "BrokenReverseConverter", + "ERROR", + "ForwardRankConverter", + "INCOMPATIBLE") + .entrySet()) + check( + details.converters().stream() + .anyMatch( + c -> + c.name().equals("fixture.components." + expected.getKey()) + && c.status().equals(expected.getValue())), + "Converter status: " + expected); + var invalid = + BindingDiscovery.inspect( + full.replace( + "converter=\"fixture.components.RankConverter\"", + "converter=\"fixture.components.BrokenReverseConverter\""), + "rank", + "both", + loader); + check( + invalid.problems().stream().anyMatch(v -> v.severity().equals("ERROR")), + "Invalid reverse metadata accepted"); + check( + System.getProperty("fixture.converter.initialized") == null, + "Discovery initialized Converter"); + } System.out.println("PASS bindings " + checks); return; }