core/kernel/source/jetbrains/mps/smodel/SNodeMatcher.java (295 lines of code) (raw):

/* * Copyright 2000-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ package jetbrains.mps.smodel; import jetbrains.mps.lang.smodel.generator.smodelAdapter.AttributeOperations; import jetbrains.mps.util.IterableUtil; import jetbrains.mps.util.Pair; import jetbrains.mps.util.SNodePresentationComparator; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.mps.openapi.language.SConcept; import org.jetbrains.mps.openapi.language.SContainmentLink; import org.jetbrains.mps.openapi.language.SProperty; import org.jetbrains.mps.openapi.language.SReferenceLink; import org.jetbrains.mps.openapi.model.SModel; import org.jetbrains.mps.openapi.model.SNode; import org.jetbrains.mps.openapi.model.SNodeAccessUtil; import org.jetbrains.mps.openapi.model.SNodeReference; import org.jetbrains.mps.openapi.model.SReference; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.BiPredicate; import java.util.function.Predicate; import java.util.stream.Collectors; /** * @author Artem Tikhomirov * @since 2022.2 */ public class SNodeMatcher implements BiPredicate<SNode, SNode> { // FIXME the only dependency to [kernel] is due to use of generated AttributeOperations, otherwise could move up to [smodel] or [openapi]? private boolean myMatchAttributeNodes = true; private PropertyMatchStrategy myProperties; private AssociationMatchStrategy myAssociations; private AggregationMatchStrategy myAggregations; public SNodeMatcher() { this(new EqualPersistentValues(), new EqualTargetReference(), new SameOrderChildMatch()); } public SNodeMatcher(@NotNull PropertyMatchStrategy pms, @NotNull AssociationMatchStrategy ams, @NotNull AggregationMatchStrategy agms ) { myProperties = pms; myAssociations = ams; myAggregations = agms; } /** * Configure matcher to ignore/respect nodes in {@code smodelAttributes} role. * Default value is {@code true}, attribute nodes are not ignored and get matched along with other child nodes. * @return {@code this} for chaining */ public SNodeMatcher withAttributes(boolean matchAttributeNodes) { myMatchAttributeNodes = matchAttributeNodes; return this; } /** * use specific strategy to match node properties * @return {@code this} for chaining */ public SNodeMatcher with(@NotNull PropertyMatchStrategy pms) { myProperties = pms; return this; } /** * use specific strategy to match node associations * @return {@code this} for chaining */ public SNodeMatcher with(@NotNull AssociationMatchStrategy ams) { myAssociations = ams; return this; } /** * use specific strategy to match aggregated children * @return {@code this} for chaining */ public SNodeMatcher with(@NotNull AggregationMatchStrategy ams) { myAggregations = ams; return this; } @Override public boolean test(SNode node, SNode node2) { return match(node, node2); } /** * Math subtree starting from specified nodes according to installed match strategies and configuration parameters of this matcher. * @param node1 first subtree root * @param node2 second subtree root * @return {@code true} if nodes and their subtrees match */ // template method, delegating matching of specific node aspects to respective matching functions public boolean match(@Nullable SNode node1, @Nullable SNode node2) { if (!matchNodePrim(node1, node2)) { return false; } //properties final Set<SProperty> properties = new HashSet<>(); node1.getProperties().forEach(properties::add); node2.getProperties().forEach(properties::add); for (SProperty property : properties) { if (!myProperties.match(node1, node2, property)) { return false; } } //-- matching references Set<SReferenceLink> referenceRoles = new HashSet<>(); for (SReference ref : node1.getReferences()) { referenceRoles.add(ref.getLink()); } for (SReference ref : node2.getReferences()) { referenceRoles.add(ref.getLink()); } for (SReferenceLink role : referenceRoles) { if (!myAssociations.match(node1, node2, role)) { return false; } } // children Set<SContainmentLink> childRoles = aggregationLinksOf(node1, node2); for (SContainmentLink role : childRoles) { if (!myAggregations.match(node1, node2, role, this)) { return false; } } return true; } private Set<SContainmentLink> aggregationLinksOf(SNode node1, SNode node2) { // XXX seems more wise/flexible to build composite structure SContainmentLink->children in the role and then match lists per role final ArrayList<SNode> nn = new ArrayList<>(); node1.getChildren().forEach(nn::add); node2.getChildren().forEach(nn::add); if (!myMatchAttributeNodes) { nn.removeIf(AttributeOperations::isAttribute); } return nn.stream().map(SNode::getContainmentLink).collect(Collectors.toSet()); } // post: if true, both node1 and node2 are not null and of the same kind protected boolean matchNodePrim(@Nullable SNode node1, @Nullable SNode node2) { if (node1 == node2) return true; if (node1 == null || node2 == null) { return false; } return node1.getConcept().equals(node2.getConcept()); } public interface PropertyMatchStrategy { boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SProperty property); } /** * Match properties if their low-level persistence values are the same */ public static class EqualPersistentValues implements PropertyMatchStrategy { @Override public boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SProperty property) { return Objects.equals(node1.getProperty(property), node2.getProperty(property)); } } /** * Match properties if their user-perceived values (those affected by property constraints and type conversions) are the same */ public static class EqualUserValues implements PropertyMatchStrategy { @Override public boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SProperty property) { final Object v1 = SNodeAccessUtil.getPropertyValue(node1, property); final Object v2 = SNodeAccessUtil.getPropertyValue(node2, property); return Objects.equals(v1, v2); } } public interface AssociationMatchStrategy { // pre: at least one of nodes has 'link' association boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SReferenceLink link); default boolean matchLinkTarget(SNode n1, SNode n2) { if (n1 == null || n2 == null) { return false; // both null - no match, it's link target, we expect it to resolve to something } if (!n1.getConcept().equals(n2.getConcept())) { return false; } if (!new HashSet<>(IterableUtil.asCollection(n1.getProperties())).equals(new HashSet<>(IterableUtil.asCollection(n2.getProperties())))) { return false; } if (IterableUtil.count(n1.getReferences()) != IterableUtil.count(n2.getReferences())) { return false; } if (IterableUtil.count(n1.getChildren()) != IterableUtil.count(n2.getChildren())) { return false; } // FIXME revisit, perhaps, shall use approach similar to that in NodesMatcher and its obscure myMap // YES, seems that checking meta-model info is not enough, see MPS-35779 return true; } } /** * Matches association link targets by comparing their target {@code SNodeReference} */ public static class EqualTargetReference implements AssociationMatchStrategy { @Override public boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SReferenceLink link) { final SReference r1 = node1.getReference(link); final SReference r2 = node2.getReference(link); if (r1 == null || r2 == null) { assert r1 != null || r2 != null : "at least one node is supposed to have the link"; return false; } final SNodeReference t1 = r1.getTargetNodeReference(); final SNodeReference t2 = r2.getTargetNodeReference(); if (Objects.equals(t1, t2)) { return true; } final SModel m1 = r1.getSourceNode().getModel(); final SModel m2 = r2.getSourceNode().getModel(); // different nodes within same model - no match! if (m1 != null && m2 != null && m1 != m2) { // references within respective models of their sources if (m1.getReference().equals(t1.getModelReference()) && m2.getReference().equals(t2.getModelReference())) { return matchLinkTarget(m1.getNode(t1.getNodeId()), m2.getNode(t2.getNodeId())); } // fall through } return false; } } /** * Association link matches if both values point to the same {@code SNode} instance. * {@code null} target is considered legitimate value. */ public static class IdenticalTargetNode implements AssociationMatchStrategy { @Override public boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SReferenceLink link) { final SNode target1 = node1.getReferenceTarget(link); final SNode target2 = node2.getReferenceTarget(link); if (target1 == target2) { return true; } final SModel m1 = node1.getModel(); final SModel m2 = node2.getModel(); if (m1 == null || m2 == null) { return false; } if (m1 == m2) { // same model but different target nodes return false; } if (m1 == target1.getModel() && m2 == target2.getModel()) { // local (respective model of matched node) targets return matchLinkTarget(target1, target2); } return false; } } public interface AggregationMatchStrategy { // pre: at least one of nodes has 'link' aggregation boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SContainmentLink link, @NotNull BiPredicate<SNode, SNode> childMatcher); } /** * Check child nodes recursively using supplied SNodeMatcher, iterating one by one in the order children presented by a parent. */ public static class SameOrderChildMatch implements AggregationMatchStrategy { @Override public boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SContainmentLink link, @NotNull BiPredicate<SNode, SNode> childMatcher) { Iterator<? extends SNode> childrenIterator = node1.getChildren(link).iterator(); for (SNode child2 : node2.getChildren(link)) { SNode child1 = childrenIterator.hasNext() ? childrenIterator.next() : null; if (!childMatcher.test(child1, child2)) { return false; } } while (childrenIterator.hasNext()) { SNode child1 = childrenIterator.next(); SNode child2 = null; if (!childMatcher.test(child1, child2)) { return false; } } return true; } } public static class AnyOrderChildMatch implements AggregationMatchStrategy { private final boolean myFailFast; public AnyOrderChildMatch() { myFailFast = true; } public AnyOrderChildMatch(boolean failFast) { myFailFast = failFast; } private static Map<SConcept, List<SNode>> byConceptAndSorted(Iterable<? extends SNode> nodes, Comparator<SNode> npc) { LinkedHashMap<SConcept, List<SNode>> rv = new LinkedHashMap<>(); for (SNode n : nodes) { rv.computeIfAbsent(n.getConcept(), (c) -> new ArrayList<>()).add(n); } rv.values().forEach(l -> l.sort(npc)); return rv; } public static List<Pair<SNode,SNode>> arrangeSameConceptAndName(Iterable<? extends SNode> first, Iterable<? extends SNode> second) { final Comparator<SNode> npc = new SNodePresentationComparator(); final Map<SConcept, List<SNode>> m1 = byConceptAndSorted(first, npc); final Map<SConcept, List<SNode>> m2 = byConceptAndSorted(second, npc); List<Pair<SNode,SNode>> rv = new ArrayList<>(); for (SNode n : first) { final boolean removed = m1.get(n.getConcept()).remove(n); if (!removed) { throw new IllegalStateException(); } final List<SNode> counterpart = m2.get(n.getConcept()); if (counterpart == null) { // shall I indicate it's a different scenario to 'nodes of this concept present, but none with same name)? rv.add(new Pair<>(n, null)); } else { final int i = Collections.binarySearch(counterpart, n, npc); if (i >= 0) { final SNode cn = counterpart.remove(i); rv.add(new Pair<>(n, cn)); } else { rv.add(new Pair<>(n, null)); } } } for (SNode n : second) { final boolean removed = m2.get(n.getConcept()).remove(n); if (!removed) { // already matched with some m1 node continue; } final List<SNode> counterpart = m1.get(n.getConcept()); if (counterpart == null) { rv.add(new Pair<>(null, n)); } else { final int i = Collections.binarySearch(counterpart, n, npc); if (i >= 0) { assert false : "How come we iterated over all nodes in 'first' but there are still some left?!"; final SNode cn = counterpart.remove(i); rv.add(new Pair<>(cn, n)); } else { rv.add(new Pair<>(null, n)); } } } final Predicate<List<SNode>> isNotEmpty = ((Predicate<List<SNode>>) List::isEmpty).negate(); if (m1.values().stream().anyMatch(isNotEmpty) || m2.values().stream().anyMatch(isNotEmpty)) { throw new IllegalStateException(); } return rv; } @Override public boolean match(@NotNull SNode node1, @NotNull SNode node2, @NotNull SContainmentLink link, @NotNull BiPredicate<SNode, SNode> childMatcher) { final List<Pair<SNode, SNode>> pairs = arrangeSameConceptAndName(node1.getChildren(link), node2.getChildren(link)); boolean match = true; for (Pair<SNode, SNode> p : pairs) { match &= childMatcher.test(p.o1, p.o2); if (myFailFast && !match) { return false; } } return match; } } }