323 lines
12 KiB
Java
323 lines
12 KiB
Java
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);
|
|
}
|
|
}
|