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 elements = new ArrayList<>(); final List entries = new ArrayList<>(); private final List imports = new ArrayList<>(); private final Map 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 errors() { var errors = new ArrayList(); var ids = new HashMap(); for (var e : elements) { String id = e.getAttributeNS(FX, "id"); if (!id.isEmpty()) ids.merge(id, 1, Integer::sum); } var occupied = new HashSet(); 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 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 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(); 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(); Map 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() + ""; 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() + ""; 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 + ""); return new Replacement(t.closeStart(), t.closeStart(), eol + child + eol); } }