MergedStyleNode.java
/* ========================================================================
* PlantUML : a free UML diagram generator
* ========================================================================
*
* (C) Copyright 2009-2024, Arnaud Roques
*
* Project Info: https://plantuml.com
*
* If you like this project or if you find it useful, you can support us at:
*
* https://plantuml.com/patreon (only 1$ per month!)
* https://plantuml.com/paypal
*
* This file is part of PlantUML.
*
* PlantUML is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PlantUML distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
* License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
* USA.
*
*
* Original Author: Arnaud Roques
*
*
*/
package net.sourceforge.plantuml.style.parser2;
import java.util.EnumMap;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import net.sourceforge.plantuml.style.AutomaticCounter;
import net.sourceforge.plantuml.style.PName;
import net.sourceforge.plantuml.style.SName;
/**
* One node of the canonical, merged style tree: unlike {@link RawStyleRule}, there is at
* most one node per (selector name, {@code *}-or-not) pair at a given level, whatever comma
* lists or repeated declarations produced it in the source file.
*
* Building this tree from a {@link RawStyleSheet} does exactly two things the raw parse
* tree deliberately left undone:
* <ul>
* <li>a comma-separated selector list, e.g. {@code composite,package { title {...} } },
* is expanded so each alternative gets its own independent node (so a later declaration
* of {@code package { title {...} } } elsewhere only ever affects {@code package}, never
* {@code composite});</li>
* <li>repeated declarations of the very same selector path <b>with the same {@code *}-ness</b>,
* e.g. two separate top-level {@code mindmapDiagram { ... }} blocks, are folded into a single
* node: properties are overwritten by whichever declaration comes last in the file (the same
* last-one-wins rule the legacy {@code Style#mergeWith} already applies), and children
* accumulate across every occurrence.</li>
* </ul>
*
* A selector declared once plain and once starred, e.g. {@code .europeStyle { node { FontSize
* 20 } } } alongside {@code .europeStyle * { node { FontColor red } } }, is deliberately
* <b>not</b> folded together the way two identically-starred declarations are: they land in two
* separate child slots (see {@link #getOrCreateChild}), because {@code *} changes what the
* declaration actually means -- FontSize 20 must stay on the Europe node alone, while FontColor
* red must cascade to every descendant too. The legacy character-level parser never had to get
* this right on purpose: its {@code Context#push} always allocates a brand new {@code Context}
* per occurrence, so a plain and a starred declaration of the same name were simply never the
* same object to begin with. Collapsing them into one node here (as an earlier version of this
* class did, keying a child purely by selector name) would silently let FontSize leak onto
* England, Germany and Spain along with FontColor -- a real regression, not a hypothetical one.
*
* What this tree still does <b>not</b> do: turn a nested path such as
* {@code sequenceDiagram > participant} into the flat, order-independent signature
* (a set of {@link SName}) the legacy {@link net.sourceforge.plantuml.style.StyleSignature}
* matches against, or resolve {@code depth(n)} into an actual matching rule. Those are later
* steps; see {@code net.sourceforge.plantuml.style.LegacyStyleFlattener}.
*
* A property set inside {@code @media (prefers-color-scheme:dark) { ... }} is not kept apart
* from its plain (light) counterpart either: both land in the very same node, folded into one
* {@link PrioritizedValue} carrying both -- see {@link #mergeRule}.
*/
public final class MergedStyleNode {
/**
* Plain (non-cascading) and starred (cascading) declarations of the very same selector name
* are kept as two independent child slots, never merged into one -- see {@link #star} for why.
*/
private final Map<SName, MergedStyleNode> namedChildren = new EnumMap<SName, MergedStyleNode>(SName.class);
private final Map<SName, MergedStyleNode> starredNamedChildren = new EnumMap<SName, MergedStyleNode>(
SName.class);
private final Map<String, MergedStyleNode> otherChildren = new LinkedHashMap<String, MergedStyleNode>();
private final Map<String, MergedStyleNode> starredOtherChildren = new LinkedHashMap<String, MergedStyleNode>();
private final Map<String, RawSelector.Kind> otherChildKinds = new LinkedHashMap<String, RawSelector.Kind>();
private final Map<PName, PrioritizedValue> properties = new EnumMap<PName, PrioritizedValue>(PName.class);
/**
* Whether the selector that led to this exact node carried a trailing {@code *} -- fixed at
* construction (see why in the class javadoc): a node reached without a star and a node
* reached with one are simply different nodes, even when they happen to sit at the same
* selector name inside the same parent (see {@link #getOrCreateChild}).
*/
private final boolean star;
private MergedStyleNode(boolean star) {
this.star = star;
}
public static MergedStyleNode newTopLevelContainer() {
return new MergedStyleNode(false);
}
/**
* Merges one raw declaration -- and, recursively, everything nested inside it -- into
* this node, which stands for its parent selector (the top-level container for a
* top-level declaration).
*
* {@code counter} assigns each property value an increasing priority as it is met, exactly
* like the legacy {@code StyleBuilder} (itself an {@link AutomaticCounter}) does when
* loading a .skin file -- so it must be the very same counter for every {@code mergeRule}
* call across one sheet, {@code @media} content included, for priorities to be comparable
* across every declaration in the file.
*
* {@code dark} says whether {@code rule} sits inside an {@code @media} block: every
* property it sets is then wrapped as a {@link PrioritizedValue#dark(String, int) dark}
* value instead of a {@link PrioritizedValue#light(String, int) light} one, and
* {@link PrioritizedValue#mergeWith(PrioritizedValue)} takes care of folding it together
* with whatever light value the very same property already has at this node (in file order,
* either one may come first). This mirrors the legacy parser's own {@code @media} handling
* ({@code net.sourceforge.plantuml.style.parser.StyleParser}'s {@code scheme} field): it
* does not look at the actual media condition text at all, it only cares that some
* {@code @}-rule switched parsing into "dark" mode.
*/
public void mergeRule(RawStyleRule rule, AutomaticCounter counter, boolean dark) {
mergeRule(rule, counter, dark, new IdentityHashMap<RawStyleRule, Map<PName, PrioritizedValue>>());
}
/**
* Merges every top-level rule in {@code raw} into this node, {@code @media} content
* dispatched to its dark half exactly as the plain case is -- what
* {@code net.sourceforge.plantuml.style.StyleLoader#parseStyleText} calls to fold a whole
* .skin file, or a hand-written {@code <style>...</style>} block, into one throwaway tree,
* immediately flattened back to legacy {@code net.sourceforge.plantuml.style.Style} objects
* by {@code LegacyStyleFlattener}. {@code counter} must be the very same one across every
* call contributing to one logical load (a whole file, or a file followed by its overlays),
* exactly like a single top-level {@link #mergeRule} call requires.
*/
public void mergeAll(RawStyleSheet raw, AutomaticCounter counter) {
for (RawStyleRule rule : raw.getRules()) {
if (rule.isMediaBlock() == false) {
mergeRule(rule, counter, false);
continue;
}
// Like the legacy parser, the actual @-rule condition text is not inspected: its
// mere presence switches its content to the dark half of PrioritizedValue.
for (RawStyleRule nested : rule.getChildren())
mergeRule(nested, counter, true);
}
}
/**
* {@code ownValuesCache} draws each property of a given {@link RawStyleRule} node its
* priority exactly once, no matter how many times this method ends up visiting that very
* same node -- which happens whenever an ancestor selector is a comma list, e.g.
* {@code composite,package { title { FontStyle bold } } }: the "title" rule is reached once
* per alternative ({@code composite}'s child, then {@code package}'s child), and without this
* cache each visit would draw a fresh priority from {@code counter}, silently giving
* {@code package}'s title a higher (later) priority than {@code composite}'s for the exact
* same declaration. The legacy parser never had this problem: its {@code Context} builds one
* {@code Value} per property and fans out that very instance to every accumulated signature,
* rather than redrawing one per alternative. Scoped fresh to one top-level
* {@link #mergeRule(RawStyleRule, AutomaticCounter, boolean)} call -- a {@link RawStyleRule}
* is only ever revisited within the recursion of the one top-level rule it is nested under,
* never shared across top-level rules or files.
*/
private void mergeRule(RawStyleRule rule, AutomaticCounter counter, boolean dark,
Map<RawStyleRule, Map<PName, PrioritizedValue>> ownValuesCache) {
if (rule.isMediaBlock())
throw new IllegalArgumentException("An @media block is not a selector; merge its children instead");
Map<PName, PrioritizedValue> ownValues = ownValuesCache.get(rule);
if (ownValues == null) {
ownValues = new EnumMap<PName, PrioritizedValue>(PName.class);
for (Map.Entry<PName, String> ent : rule.getProperties().entrySet()) {
final int priority = counter.getNextInt();
ownValues.put(ent.getKey(), dark ? PrioritizedValue.dark(ent.getValue(), priority)
: PrioritizedValue.light(ent.getValue(), priority));
}
ownValuesCache.put(rule, ownValues);
}
for (RawSelector selector : rule.getSelectors()) {
final MergedStyleNode child = getOrCreateChild(selector, rule.isStar());
for (Map.Entry<PName, PrioritizedValue> ent : ownValues.entrySet()) {
final PrioritizedValue existing = child.properties.get(ent.getKey());
child.properties.put(ent.getKey(), ent.getValue().mergeWith(existing));
}
for (RawStyleRule nested : rule.getChildren())
child.mergeRule(nested, counter, dark, ownValuesCache);
}
}
/**
* {@code star} picks which of the two child slots for this selector to use -- see the class
* javadoc for why a plain and a starred declaration of the very same name must never share
* one. Repeated declarations that agree on {@code *}-ness still land in, and merge within,
* the very same slot, exactly as before.
*/
private MergedStyleNode getOrCreateChild(RawSelector selector, boolean star) {
if (selector.getKind() == RawSelector.Kind.NAME) {
final Map<SName, MergedStyleNode> children = star ? starredNamedChildren : namedChildren;
MergedStyleNode child = children.get(selector.getSName());
if (child == null) {
child = new MergedStyleNode(star);
children.put(selector.getSName(), child);
}
return child;
}
final String key = selector.canonicalOtherKey();
final Map<String, MergedStyleNode> children = star ? starredOtherChildren : otherChildren;
MergedStyleNode child = children.get(key);
if (child == null) {
child = new MergedStyleNode(star);
children.put(key, child);
otherChildKinds.put(key, selector.getKind());
}
return child;
}
/**
* Which {@link RawSelector.Kind} produced the "other" child stored under this canonical
* key -- {@code STEREOTYPE}, {@code DEPTH} or {@code UNKNOWN} -- so a later compilation
* step can tell a stereotype selector from an unrecognized one, which
* {@link RawSelector#canonicalOtherKey()} alone does not always disambiguate.
*/
public RawSelector.Kind getOtherChildKind(String canonicalKey) {
return otherChildKinds.get(canonicalKey);
}
/** Children reached through a plain (non-starred) named selector. See {@link #getStarredNamedChildren()}. */
public Map<SName, MergedStyleNode> getNamedChildren() {
return namedChildren;
}
/**
* Children reached through a starred named selector, e.g. the {@code node} in
* {@code europeStyle * { node {...} } }. Kept apart from {@link #getNamedChildren()} rather
* than merged with it -- see the class javadoc -- so a caller building a child signature must
* visit both maps and {@code addStar()} unconditionally for entries found here.
*/
public Map<SName, MergedStyleNode> getStarredNamedChildren() {
return starredNamedChildren;
}
/** Children reached through a plain (non-starred) stereotype/depth/unknown selector. See {@link #getStarredOtherChildren()}. */
public Map<String, MergedStyleNode> getOtherChildren() {
return otherChildren;
}
/** The starred counterpart of {@link #getOtherChildren()} -- see {@link #getStarredNamedChildren()}. */
public Map<String, MergedStyleNode> getStarredOtherChildren() {
return starredOtherChildren;
}
/**
* Properties declared anywhere for this exact selector, already merged -- light and dark
* declarations for the same property folded into one {@link PrioritizedValue}, per
* {@link #mergeRule}.
*/
public Map<PName, PrioritizedValue> getProperties() {
return properties;
}
public PrioritizedValue getProperty(PName name) {
return properties.get(name);
}
public boolean isStar() {
return star;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder();
print(sb, 0);
return sb.toString();
}
private void print(StringBuilder sb, int indent) {
for (Map.Entry<PName, PrioritizedValue> ent : properties.entrySet()) {
indent(sb, indent);
sb.append(ent.getKey()).append(" = ").append(ent.getValue()).append('\n');
}
printChildren(sb, indent, namedChildren);
printChildren(sb, indent, starredNamedChildren);
printChildren(sb, indent, otherChildren);
printChildren(sb, indent, starredOtherChildren);
}
private static <K> void printChildren(StringBuilder sb, int indent, Map<K, MergedStyleNode> children) {
for (Map.Entry<K, MergedStyleNode> ent : children.entrySet()) {
indent(sb, indent);
sb.append('[').append(ent.getKey()).append(ent.getValue().star ? " *" : "").append("] {\n");
ent.getValue().print(sb, indent + 1);
indent(sb, indent);
sb.append("}\n");
}
}
private static void indent(StringBuilder sb, int level) {
for (int i = 0; i < level; i++)
sb.append(" ");
}
}