Add shared source-preserving Computed binding inspector
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user