Add shared source-preserving Computed binding inspector

This commit is contained in:
2026-11-13 13:42:19 +01:00
parent 99d93a11fc
commit 510ce35b60
12 changed files with 639 additions and 224 deletions
@@ -0,0 +1,322 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*;
import org.w3c.dom.*;
/** Binding edit intents translated into lexical ranges in one immutable source revision. */
final class BindingDocument {
static final String FX = "http://javafx.com/fxml/1", PACKAGE = "de.neitzel.fx.view.";
enum Kind {
DIRECT,
COMPUTED;
String element() {
return this == DIRECT ? "DirectBinding" : "ComputedBinding";
}
}
record Values(
String control,
String property,
String path,
String direction,
String converter,
String method) {
Values(String control, String property, String path, String direction, String converter) {
this(control, property, path, direction, converter, "");
}
static Values computed(String control, String property, String method) {
return new Values(control, property, "", "view", "", method);
}
}
record Entry(int index, Values values, boolean editable) {
public String toString() {
return values.control()
+ "."
+ values.property()
+ " ← "
+ (values.method().isEmpty() ? values.path() : values.method());
}
}
static final class Conflict extends IllegalArgumentException {
Conflict(String target) {
super(
"Bindings already target "
+ target
+ ". Select Replace conflicting bindings to replace them in this action.");
}
}
final Kind kind;
final String text;
final Document document;
final XmlSource source;
final List<Element> elements = new ArrayList<>();
final List<Entry> entries = new ArrayList<>();
private final List<String> imports = new ArrayList<>();
private final Map<Element, Integer> indexes = new IdentityHashMap<>();
BindingDocument(String text) throws Exception {
this(text, Kind.DIRECT);
}
BindingDocument(String text, Kind kind) throws Exception {
this.kind = kind;
this.text = text;
document = FxmlProjection.parse(text);
source = new XmlSource(text);
for (Node n = document.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import"))
imports.add(pi.getData().trim());
var nodes = document.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
elements.add(e);
indexes.put(e, i);
}
if (elements.size() != source.tags.size())
throw new IllegalArgumentException("XML source ranges do not match parsed elements");
for (var e : elements)
if (binding(e, kind.element()))
entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e)));
}
private boolean named(Element e, String type) {
if (e.getTagName().equals(PACKAGE + type)) return true;
if (!e.getTagName().equals(type)) return false;
boolean own = imports.contains(PACKAGE + type) || imports.contains(PACKAGE + "*");
boolean other =
imports.stream().anyMatch(i -> i.endsWith("." + type) && !i.equals(PACKAGE + type));
return own && !other;
}
private boolean list(Element e) {
return named(e, "BindingList")
&& e.getParentNode() instanceof Element p
&& FX.equals(p.getNamespaceURI())
&& p.getLocalName().equals("define")
&& p.getParentNode() == document.getDocumentElement();
}
private Element bindingList(Element e) {
Node parent = e.getParentNode();
if (parent instanceof Element p && p.getTagName().equals("elements"))
parent = p.getParentNode();
return parent instanceof Element p && list(p) ? p : null;
}
private boolean binding(Element e, String name) {
return named(e, name) && bindingList(e) != null;
}
private Values values(Element e) {
if (kind == Kind.COMPUTED)
return Values.computed(
e.getAttribute("control"), e.getAttribute("property"), e.getAttribute("method"));
return new Values(
e.getAttribute("control"),
e.getAttribute("property"),
e.getAttribute("path"),
e.getAttribute("direction"),
e.getAttribute("converter"));
}
private static boolean hasElementChildren(Element e) {
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element) return true;
return false;
}
private Element entry(int index) {
return entries.stream()
.filter(e -> e.index() == index)
.findFirst()
.map(e -> elements.get(index))
.orElseThrow(() -> new IllegalArgumentException("Binding selection is no longer valid"));
}
List<String> errors() {
var errors = new ArrayList<String>();
var ids = new HashMap<String, Integer>();
for (var e : elements) {
String id = e.getAttributeNS(FX, "id");
if (!id.isEmpty()) ids.merge(id, 1, Integer::sum);
}
var occupied = new HashSet<String>();
for (var e : elements)
if (binding(e, "DirectBinding") || binding(e, "ComputedBinding")) {
String target = e.getAttribute("control") + "." + e.getAttribute("property");
if (!occupied.add(target)) errors.add("Duplicate binding target: " + target);
if (!hasElementChildren(e)) {
if (ids.getOrDefault(e.getAttribute("control"), 0) != 1)
errors.add("Missing or ambiguous control: " + e.getAttribute("control"));
if (e.getAttribute("property").isBlank()) errors.add("Missing property: " + target);
if (binding(e, "ComputedBinding")
&& !e.getAttribute("method")
.matches("#?[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"))
errors.add("Invalid or missing Controller method: " + target);
if (binding(e, "DirectBinding")) {
if (!e.getAttribute("path")
.matches(
"[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*"))
errors.add("Invalid or missing Model path: " + target);
if (!Set.of("view", "model", "both").contains(e.getAttribute("direction")))
errors.add("Invalid or missing direction: " + target);
}
}
}
return List.copyOf(errors);
}
List<Replacement> remove(int index) {
Element e = entry(index);
var t = source.tags.get(indexes.get(e));
return List.of(new Replacement(t.start(), t.end(), ""));
}
List<Replacement> write(Integer index, Values value, boolean replaceConflicts) {
if (value.control().isBlank() || value.property().isBlank())
throw new IllegalArgumentException("Choose a control and property");
Element original = index == null ? null : entry(index);
if (original != null && hasElementChildren(original))
throw new IllegalArgumentException(
"This declaration uses nested property elements; edit it in Source.");
var conflicts =
elements.stream()
.filter(
e ->
e != original
&& (binding(e, "DirectBinding") || binding(e, "ComputedBinding"))
&& e.getAttribute("control").equals(value.control())
&& e.getAttribute("property").equals(value.property()))
.toList();
if (!conflicts.isEmpty() && !replaceConflicts)
throw new Conflict(value.control() + "." + value.property());
var edits = new ArrayList<Replacement>();
for (var e : conflicts) {
var t = source.tags.get(indexes.get(e));
edits.add(new Replacement(t.start(), t.end(), ""));
}
if (original != null) {
var changed = new LinkedHashMap<String, String>();
Map<String, String> fields =
kind == Kind.COMPUTED
? Map.of(
"control",
value.control(),
"property",
value.property(),
"method",
value.method())
: Map.of(
"control",
value.control(),
"property",
value.property(),
"path",
value.path(),
"direction",
value.direction(),
"converter",
value.converter());
for (var f : fields.entrySet())
if (!original.getAttribute(f.getKey()).equals(f.getValue()))
changed.put(
f.getKey(),
f.getValue().isEmpty() && f.getKey().equals("converter") ? null : f.getValue());
edits.addAll(source.attributes(index, changed));
} else {
String node =
"<"
+ PACKAGE
+ kind.element()
+ " control=\""
+ XmlSource.escape(value.control(), '"')
+ "\" property=\""
+ XmlSource.escape(value.property(), '"')
+ "\"";
if (kind == Kind.COMPUTED)
node += " method=\"" + XmlSource.escape(value.method(), '"') + "\"";
else
node +=
" path=\""
+ XmlSource.escape(value.path(), '"')
+ "\" direction=\""
+ XmlSource.escape(value.direction(), '"')
+ "\""
+ (value.converter().isEmpty()
? ""
: " converter=\"" + XmlSource.escape(value.converter(), '"') + "\"");
node += "/>";
var lists = elements.stream().filter(this::list).toList();
if (!lists.isEmpty()) {
Element container = lists.getFirst();
for (Node n = container.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element e && e.getTagName().equals("elements")) {
container = e;
break;
}
edits.add(append(container, node, ""));
} else {
String body =
"<"
+ PACKAGE
+ "BindingList>"
+ newline()
+ " "
+ node
+ newline()
+ "</"
+ PACKAGE
+ "BindingList>";
Element root = document.getDocumentElement();
Element define =
elements.stream()
.filter(
e ->
FX.equals(e.getNamespaceURI())
&& e.getLocalName().equals("define")
&& e.getParentNode() == root)
.findFirst()
.orElse(null);
if (define != null) edits.add(append(define, body, ""));
else {
String prefix = root.lookupPrefix(FX), attribute = "";
if (prefix == null) {
prefix = "fx";
int suffix = 1;
while (root.lookupNamespaceURI(prefix) != null) prefix = "fx" + (suffix++);
attribute = " xmlns:" + prefix + "=\"" + FX + "\"";
}
body =
"<" + prefix + ":define>" + newline() + body + newline() + "</" + prefix + ":define>";
var tag = source.tags.get(indexes.get(root));
if (!attribute.isEmpty() && !tag.selfClosing())
edits.add(new Replacement(tag.insert(), tag.insert(), attribute));
edits.add(append(root, body, tag.selfClosing() ? attribute : ""));
}
}
}
return List.copyOf(edits);
}
private String newline() {
return text.contains("\r\n") ? "\r\n" : "\n";
}
private Replacement append(Element container, String child, String attributes) {
var t = source.tags.get(indexes.get(container));
String eol = newline();
if (t.selfClosing())
return new Replacement(
t.insert(),
t.openEnd(),
attributes + ">" + eol + child + eol + "</" + container.getTagName() + ">");
return new Replacement(t.closeStart(), t.closeStart(), eol + child + eol);
}
}