Skip to content

Instantly share code, notes, and snippets.

@lukaseder
Created March 5, 2020 16:06
Show Gist options
  • Select an option

  • Save lukaseder/4d2ae9984d8c5ba1e854afea84c87ee9 to your computer and use it in GitHub Desktop.

Select an option

Save lukaseder/4d2ae9984d8c5ba1e854afea84c87ee9 to your computer and use it in GitHub Desktop.

Revisions

  1. lukaseder created this gist Mar 5, 2020.
    713 changes: 713 additions & 0 deletions ApiDiff.java
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,713 @@
    /*
    * Copyright (c) 2009-2015, Data Geekery GmbH (http://www.datageekery.com)
    * All rights reserved.
    *
    * This work is dual-licensed
    * - under the Apache Software License 2.0 (the "ASL")
    * - under the jOOQ License and Maintenance Agreement (the "jOOQ License")
    * =============================================================================
    * You may choose which license applies to you:
    *
    * - If you're using this work with Open Source databases, you may choose
    * either ASL or jOOQ License.
    * - If you're using this work with at least one commercial database, you must
    * choose jOOQ License
    *
    * For more information, please visit http://www.jooq.org/licenses
    *
    * Apache Software License 2.0:
    * -----------------------------------------------------------------------------
    * Licensed under the Apache License, Version 2.0 (the "License");
    * you may not use this file except in compliance with the License.
    * You may obtain a copy of the License at
    *
    * http://www.apache.org/licenses/LICENSE-2.0
    *
    * Unless required by applicable law or agreed to in writing, software
    * distributed under the License is distributed on an "AS IS" BASIS,
    * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    * See the License for the specific language governing permissions and
    * limitations under the License.
    *
    * jOOQ License and Maintenance Agreement:
    * -----------------------------------------------------------------------------
    * Data Geekery grants the Customer the non-exclusive, timely limited and
    * non-transferable license to install and use the Software under the terms of
    * the jOOQ License and Maintenance Agreement.
    *
    * This library is distributed with a LIMITED WARRANTY. See the jOOQ License
    * and Maintenance Agreement for more details: http://www.jooq.org/licensing
    */
    package org.jooq.web;

    import static java.util.Collections.nCopies;
    import static java.util.stream.Collectors.joining;
    import static org.jooq.web.ApiDiff.Modification.added;
    import static org.jooq.web.ApiDiff.Modification.contravariance;
    import static org.jooq.web.ApiDiff.Modification.deprecated;
    import static org.jooq.web.ApiDiff.Modification.pulledup;
    import static org.jooq.web.ApiDiff.Modification.removed;
    import static org.jooq.web.Versions.API_DIFF;
    import static org.jooq.web.Versions.GROUP_ID;
    import static org.jooq.web.Versions.JAVADOC;
    import static org.jooq.web.Versions.MINOR;
    import static org.jooq.web.Versions.PATCH;
    import static org.jooq.web.Versions.VERSIONS;

    import java.io.File;
    import java.io.FileOutputStream;
    import java.io.IOException;
    import java.io.InputStream;
    import java.io.PrintStream;
    import java.util.ArrayList;
    import java.util.Comparator;
    import java.util.HashMap;
    import java.util.HashSet;
    import java.util.Iterator;
    import java.util.List;
    import java.util.Map;
    import java.util.Set;
    import java.util.concurrent.atomic.AtomicBoolean;
    import java.util.function.BiConsumer;
    import java.util.function.Consumer;
    import java.util.jar.JarEntry;
    import java.util.jar.JarFile;
    import java.util.regex.Matcher;
    import java.util.regex.Pattern;
    import java.util.stream.Collectors;
    import java.util.stream.Stream;

    import org.jooq.lambda.Seq;
    import org.jooq.tools.StringUtils;

    import org.apache.commons.io.IOUtils;
    import org.objectweb.asm.ClassReader;
    import org.objectweb.asm.Opcodes;
    import org.objectweb.asm.tree.AnnotationNode;
    import org.objectweb.asm.tree.ClassNode;
    import org.objectweb.asm.tree.MethodNode;

    /**
    * @author Lukas Eder
    */
    public class ApiDiff {

    static final int MAX_DEGREE = 5;

    final String repository;
    final Version oldVersion;
    final Version newVersion;
    final Map<String, List<String>> parameters;
    final Map<String, List<String>> parametersShortenTypes;

    public ApiDiff(String repository, Version oldVersion, Version newVersion) {
    this.repository = repository;

    this.oldVersion = oldVersion;
    this.newVersion = newVersion;

    this.parameters = new HashMap<>();
    this.parametersShortenTypes = new HashMap<>();
    }

    private void run() throws Exception {
    System.out.println("API diff generation from " + oldVersion + " to " + newVersion);

    oldVersion.init();
    newVersion.init();

    try (PrintStream out = new PrintStream(new FileOutputStream(new File("jooq.org/api-diff/" + oldVersion.minor + "-" + newVersion.minor + ".php")))) {
    Iterator<ClassNode> i1 = classes(oldVersion.classes);
    Iterator<ClassNode> i2 = classes(newVersion.classes);

    out.println(
    "<?php\n"
    + "// The following content has been generated by ApiDiff.java\n"
    + "// Please do not edit this content manually\n"
    + "require '../frame.php';\n"
    + "function getH1() {\n"
    + " return 'API diff between " + oldVersion + " and " + newVersion + "';\n"
    + "}\n"
    + "function getActiveMenu() {\n"
    + " return 'learn';\n"
    + "}\n"
    + "function printTheme() {\n"
    + " noTheme();\n"
    + "}\n"
    + "function printContent() {\n"
    + " global $root;\n"
    + "?>\n"
    + "<div class='row col col-100 col-white headline'>\n"
    + "<h1><?=getH1()?></h1>\n"
    + "</div>\n"
    + "<style>\n"
    + "<?php require 'styles.css'; ?>\n"
    + "</style>\n"
    + "<div class='row col col-100 col-white'>\n"
    + "<table border='0' cellpadding='0' cellspacing='0' class='api-diff'>\n"
    + "<tr><th>Object</th>" + (newVersion.proAnnotationAvailable() ? "<th>Edition</th>" : "") + "<th>Modification</th></tr>\n");

    Set<String> packagesPrinted = new HashSet<>();
    BiConsumer<ClassNode, Modification> classWithPackageHeader = (c, modification) -> {
    if (packagesPrinted.add(packageName(c)))
    out.println("<tr><td class='api-diff-package'><span class='package-name'>" + formatPackage(c, modification) + "</span></td>" + (newVersion.proAnnotationAvailable() ? "<td></td>" : "") + "<td class='api-diff-modification'></td></tr>");

    out.println("<tr><td class='api-diff-class api-diff-" + modification + "'><span class='" + classType(c) + "-name'>" + formatClass(c, modification) + "</span></td>" + (newVersion.proAnnotationAvailable() ? "<td>" + edition(c) + "</td>" : "") + "<td class='api-diff-modification api-diff-modification-" + modification + "'><a href='#legend'>" + (modification != null ? modification : "") + "</a></td></tr>");
    };

    append(i1, i2, CLASS_COMP,
    c -> classWithPackageHeader.accept(c, added),
    c -> classWithPackageHeader.accept(c, removed),

    (c1, c2) -> {
    Modification classModification = !deprecated(c1) && deprecated(c2)
    ? deprecated
    : null;
    AtomicBoolean classPrinted = new AtomicBoolean();
    BiConsumer<MethodNode, Modification> methodWithClassHeader = (m, modification) -> {
    if (classPrinted.compareAndSet(false, true))
    classWithPackageHeader.accept(c2, classModification);

    out.println("<tr><td class='api-diff-method api-diff-" + modification + "'><span class='method-name'>" + formatMethod(modification == removed ? c1 : c2, m, modification, modification == removed ? oldVersion : newVersion) + "</span></td>" + (newVersion.proAnnotationAvailable() ? "<td>" + edition(m) + "</td>" : "") + "<td class='api-diff-modification api-diff-modification-" + modification + "'><a href='#legend'>" + (modification != null ? modification : "") + "</a></td></tr>");
    };

    append(methods(c1), methods(c2), METHOD_COMP,
    m -> methodWithClassHeader.accept(m, addedOrPushedDown(c2, m)),
    m -> methodWithClassHeader.accept(m, removedOrPulledUp(c2, m)),
    (m1, m2) -> {
    Modification methodModification =
    !deprecated(m1) && deprecated(m2)
    ? deprecated
    : increasedContravariance(m1, m2)
    ? contravariance
    : null;

    if (methodModification != null)
    methodWithClassHeader.accept(m2, methodModification);
    }
    );
    }
    );

    out.println(
    "</table>\n"
    + "</div>\n"
    + "<?php\n"
    + "require 'legend.php';\n"
    + "?>\n"
    + "<div class='row col col-100 col-white'>\n"
    + "Eclipse icons copyright by <a href='http://www.eclipse.org/'>Eclipse</a> licensed under <a href='http://www.eclipse.org/legal/epl-v10.html'>EPL</a>. Inspiration taken from <a href='https://javaalmanac.io/'>https://javaalmanac.io/</a>"
    + "</div>");
    out.println("<?php } ?>");
    }
    }

    private boolean increasedContravariance(MethodNode m1, MethodNode m2) {
    return increasedContravariance(parameters(m1), parameters(m2));
    }

    private boolean increasedContravariance(List<String> p1, List<String> p2) {
    if (p1.size() != p2.size())
    return false;

    boolean result = false;
    for (int i = 0; i < p1.size(); i++) {
    String s1 = p1.get(i).replaceAll("\\.\\.\\.", "[]");
    String s2 = p2.get(i).replaceAll("\\.\\.\\.", "[]");

    while (s1.endsWith("[]") && s2.endsWith("[]")) {
    s1 = s1.replaceFirst("\\[\\]", "");
    s2 = s2.replaceFirst("\\[\\]", "");
    }

    if (!s1.equals(s2)) {
    if (!subtype(newVersion.classesBySourceName.get(s1), newVersion.classesBySourceName.get(s2)))
    return false;
    else
    result = true;
    }
    }

    return result;
    }

    private boolean subtype(ClassNode c1, ClassNode c2) {
    if (c1 == null || c2 == null)
    return false;

    if (c1.name.equals(c2.name))
    return true;

    if (subtype(newVersion.classes.get(c1.superName), c2))
    return true;

    if (c1.interfaces != null)
    for (String i : c1.interfaces)
    if (subtype(newVersion.classes.get(i), c2))
    return true;

    return false;
    }

    private String classType(ClassNode c) {
    if ((c.access & Opcodes.ACC_ENUM) != 0)
    return "enum";
    else if ((c.access & Opcodes.ACC_INTERFACE) != 0)
    return "interface";
    else if ((c.access & Opcodes.ACC_ANNOTATION) != 0)
    return "annotation";
    else
    return "class";
    }

    private boolean deprecated(ClassNode c) {
    return deprecated(c.visibleAnnotations);
    }

    private boolean deprecated(MethodNode m) {
    return deprecated(m.visibleAnnotations);
    }

    private boolean deprecated(List<AnnotationNode> annotations) {
    return annotations != null && annotations.stream().anyMatch(a -> a.desc.equals("Ljava/lang/Deprecated;"));
    }

    private Modification addedOrPushedDown(ClassNode c2, MethodNode m) {
    // TODO: This pusheddown algorithm is not yet correct. If the overridden method
    // is also new, then there was no push down!
    return override(c2, m) ? added : added;
    }

    private boolean override(ClassNode c2, MethodNode m) {
    return override(c2, m, false);
    }

    private boolean override(ClassNode c2, MethodNode m, boolean checkMethods) {
    if (c2 == null)
    return false;

    if (override(newVersion.classes.get(c2.superName), m, true))
    return true;

    if (c2.interfaces != null)
    for (String i : c2.interfaces)
    if (override(newVersion.classes.get(i), m, true))
    return true;

    if (checkMethods)
    for (MethodNode m2 : c2.methods)
    if (m2.name.equals(m.name) && m2.desc.equals(m.desc))
    return true;

    return false;
    }

    private Modification removedOrPulledUp(ClassNode c2, MethodNode m) {
    if (c2 == null)
    return removed;

    if (removedOrPulledUp(newVersion.classes.get(c2.superName), m) == pulledup)
    return pulledup;

    if (c2.interfaces != null)
    for (String i : c2.interfaces)
    if (removedOrPulledUp(newVersion.classes.get(i), m) == pulledup)
    return pulledup;

    for (MethodNode m2 : c2.methods)
    if (m2.name.equals(m.name) && m2.desc.equals(m.desc))
    return pulledup;

    return removed;
    }

    private static String sourceName(String bytecodeName) {
    return bytecodeName.replace("/", ".").replace("$", ".");
    }

    private String version(Modification modification) {
    return modification == removed ? oldVersion.version : newVersion.version;
    }

    private String packagePath(ClassNode c) {
    return c.name.replaceAll("(.*)/.*", "$1");
    }

    private String className(ClassNode c) {
    return sourceName(c.name.replaceAll(".*/", "")).replaceAll("1$", "1 - 22");
    }

    private String packageName(ClassNode c) {
    return sourceName(packagePath(c));
    }

    private String formatPackage(ClassNode c, Modification modification) {
    return "<a href='<?=$root?>/javadoc/" + version(modification) + "/org.jooq/" + packagePath(c) + "/package-summary.html'>" + packageName(c) + "</a>";
    }

    private String formatClass(ClassNode c, Modification modification) {
    return "<a href='<?=$root?>/javadoc/" + version(modification) + "/org.jooq/" + c.name + ".html'>" + className(c) + "</a>";
    }

    private String formatMethod(ClassNode c, MethodNode m, Modification modification, Version version) {
    return "<a href='<?=$root?>/javadoc/" + version(modification) + "/org.jooq/" + c.name + ".html#" + ("8".equals(version.javadoc) ? methodName(m, false).replaceAll("[(), ]+", "-") : methodName(m, false)) + "'>" + escape(methodName(m, true)) + "</a>" + degreeSuffix(m, MAX_DEGREE - 1);
    }

    private String degreeSuffix(MethodNode m, int degree) {
    return degreeNorMore(parameters(m), degree) ? " <sub>... and more overloads</sub>" : "";
    }

    private String methodName(MethodNode m, boolean shortenTypes) {
    return m.desc == null && StringUtils.isBlank(m.desc)
    ? m.name + "()"
    : m.name + "(" + parameters(m, shortenTypes).stream().collect(Collectors.joining(", ")) + ")";
    }

    private String edition(ClassNode c) {
    return edition(c.visibleAnnotations);
    }

    private String edition(MethodNode m) {
    return edition(m.visibleAnnotations);
    }

    private String edition(List<AnnotationNode> annotations) {
    return annotations != null && annotations.stream().anyMatch(a -> a.desc.contains("Pro")) ? "Pro" : "All";
    }

    private String escape(String string) {
    return string.replace("<", "&lt;").replace(">", "&gt;");
    }

    private final <N> void append(
    Iterator<? extends N> i1,
    Iterator<? extends N> i2,
    Comparator<? super N> comp,
    Consumer<N> create,
    Consumer<N> drop,
    BiConsumer<N, N> merge
    ) {
    N n1 = null;
    N n2 = null;

    for (;;) {
    if (n1 == null && i1.hasNext())
    n1 = i1.next();

    if (n2 == null && i2.hasNext())
    n2 = i2.next();

    if (n1 == null && n2 == null)
    break;

    int c = n1 == null
    ? 1
    : n2 == null
    ? -1
    : comp.compare(n1, n2);

    if (c < 0) {
    if (drop != null)
    drop.accept(n1);

    n1 = null;
    }
    else if (c > 0) {
    if (create != null)
    create.accept(n2);

    n2 = null;
    }
    else {
    if (merge != null)
    merge.accept(n1, n2);

    n1 = n2 = null;
    }
    }
    }

    private static final Pattern P_PARAM_DESC = Pattern.compile("\\(([^)]*)\\).*?");

    private List<String> parameters(MethodNode m) {
    return parameters(m, false);
    }

    private List<String> parameters(MethodNode m, boolean shortenTypes) {
    return parameters(m.desc, shortenTypes, (m.access & Opcodes.ACC_VARARGS) != 0);
    }

    private List<String> parameters(String desc, boolean shortenTypes, boolean varargs) {
    return (shortenTypes ? parametersShortenTypes : parameters).computeIfAbsent(desc, s -> {
    List<String> result = new ArrayList<>();

    Matcher matcher = P_PARAM_DESC.matcher(desc);
    if (matcher.find()) {
    String content = matcher.group(1);
    int dimensions = 0;

    contentLoop:
    for (int i = 0; i < content.length(); i++) {
    switch (content.charAt(i)) {
    case '[': dimensions++; continue contentLoop;
    case 'Z': result.add(array("boolean", dimensions, varargs)); break;
    case 'B': result.add(array("byte", dimensions, varargs)); break;
    case 'C': result.add(array("char", dimensions, varargs)); break;
    case 'D': result.add(array("double", dimensions, varargs)); break;
    case 'F': result.add(array("float", dimensions, varargs)); break;
    case 'I': result.add(array("int", dimensions, varargs)); break;
    case 'J': result.add(array("long", dimensions, varargs)); break;
    case 'S': result.add(array("short", dimensions, varargs)); break;
    case 'V': result.add(array("void", dimensions, varargs)); break;
    case 'L': {
    String type = content.substring(i + 1, content.indexOf(';', i));
    result.add(array(shortenTypes ? sourceName(type.replaceAll(".*/", "")) : sourceName(type), dimensions, varargs));
    i += type.length() + 1;
    break;
    }
    }

    dimensions = 0;
    }
    }

    return result;
    });
    }

    private String array(String type, int dimensions, boolean varargs) {
    for (int i = 0; i < dimensions; i++)
    type = type + (varargs && i == dimensions - 1 ? "..." : "[]");

    return type;
    }

    private final Comparator<MethodNode> METHOD_COMP = Comparator
    .<MethodNode, String>comparing(m -> m.name)
    .thenComparing((m1, m2) -> {
    List<String> p1 = parameters(m1);
    List<String> p2 = parameters(m2);
    int s1 = p1.size();
    int s2 = p2.size();
    int c = s1 - s2;

    if (c != 0)
    return c;

    for (int i = 0; i < s1; i++) {
    c = p1.get(i).compareTo(p2.get(i));

    if (c != 0)
    if (increasedContravariance(p1, p2))
    return 0;
    else
    return c;
    }

    return 0;
    });

    private final Comparator<ClassNode> CLASS_COMP = Comparator.comparing(c -> c.name);

    private boolean degreeNorMore(List<String> p, int degree) {
    if (p.size() < degree)
    return false;

    return nCopies(p.size() - (degree - 1), p.get(p.size() - 1)).equals(p.subList((degree - 1), p.size()));
    }

    private Iterator<MethodNode> methods(ClassNode n) {
    return Seq.seq(n.methods)
    .filter(m -> (m.access & Opcodes.ACC_PUBLIC) != 0)
    .filter(m -> Seq.seq(m.visibleAnnotations).noneMatch(a -> "Lorg/jooq/Internal;".equals(a.desc)))
    .filter(m -> !degreeNorMore(parameters(m), MAX_DEGREE))
    .sorted(METHOD_COMP)
    .iterator();
    }

    private Iterator<ClassNode> classes(Map<String, ClassNode> cache) {
    return cache.values()
    .stream()
    .sorted(CLASS_COMP)
    .iterator();
    }

    public static void main(String[] args) throws Exception {
    String repository = "C:/users/lukas/.m2/repository";

    Version[] versions = new Version[VERSIONS.length];
    for (int i = 0; i < versions.length; i++)
    versions[i] = new Version(repository, VERSIONS[i][GROUP_ID], VERSIONS[i][MINOR], VERSIONS[i][PATCH], VERSIONS[i][JAVADOC]);

    System.out.println("API diff overview generation");
    try (PrintStream out = new PrintStream(new FileOutputStream(new File("jooq.org/api-diff/index.php")))) {
    out.println(
    "<?php\n"
    + "// The following content has been generated by ApiDiff.java\n"
    + "// Please do not edit this content manually\n"
    + "require '../frame.php';\n"
    + "function getH1() {\n"
    + " return 'API diff between all jOOQ versions';\n"
    + "}\n"
    + "function getActiveMenu() {\n"
    + " return 'learn';\n"
    + "}\n"
    + "function printTheme() {\n"
    + " noTheme();\n"
    + "}\n"
    + "function printContent() {\n"
    + " global $root;\n"
    + "?>\n"
    + "<div class='row col col-100 col-white headline'>\n"
    + "<h1><?=getH1()?></h1>\n"
    + "</div>\n"
    + "<style>\n"
    + "<?php require 'styles.css'; ?>\n"
    + "</style>\n"
    + "<div class='row col col-100 col-white'>\n"
    + "<table border='0' cellpadding='0' cellspacing='0' class='api-diff api-diff-overview'>\n"
    + "<tr><th>From</th>" +
    Stream.of(VERSIONS)
    .filter(v -> "true".equals(v[API_DIFF]))
    .skip(1)
    .map(v -> "<th>To " + v[MINOR] + "</th>")
    .collect(joining())
    + "</tr>\n");

    for (int i1 = 0; i1 < VERSIONS.length; i1++) {
    int i = i1;

    if ("true".equals(VERSIONS[i1][API_DIFF])
    && Seq.of(VERSIONS)
    .zipWithIndex()
    .filter(v -> "true".equals(v.v1[API_DIFF]))
    .skip(1)
    .anyMatch(v -> i < v.v2)
    ) {
    out.println("<tr><td>" + VERSIONS[i1][MINOR] + "</td>" +
    Seq.of(VERSIONS)
    .zipWithIndex()
    .filter(v -> "true".equals(v.v1[API_DIFF]))
    .skip(1)
    .map(v -> "<td>" + (i < v.v2 ? ("<a href='<?=$root?>/api-diff/" + VERSIONS[i][MINOR] + "-" + v.v1[MINOR] + "'>Diff</a>") : "") + "</td>")
    .collect(joining())
    + "</tr>\n");
    }
    }

    out.println(
    "</table>\n"
    + "</div>");
    out.println("<?php } ?>");
    }

    for (int i1 = 0; i1 < VERSIONS.length; i1++)
    for (int i2 = i1 + 1; i2 < VERSIONS.length; i2++)
    if ("true".equals(VERSIONS[i1][API_DIFF]) && "true".equals(VERSIONS[i2][API_DIFF]))
    new ApiDiff(repository, versions[i1], versions[i2]).run();
    }

    static class Version {
    static final Pattern P_TUPLE_TYPE = Pattern.compile("^org/jooq/.*(1\\d|2\\d|[2-9])$");

    final String repository;
    final String groupId;
    final String minor;
    final String version;
    final String javadoc;
    final File jarFile;
    final Map<String, ClassNode> classes;
    final Map<String, ClassNode> classesBySourceName;
    boolean init;
    Boolean proAnnotationAvailable;


    Version(String repository, String groupId, String minor, String version, String javadoc) {
    this.repository = repository;
    this.groupId = groupId;
    this.minor = minor;
    this.version = version;
    this.javadoc = javadoc;

    this.jarFile = lookupJarFile();
    this.classes = new HashMap<>();
    this.classesBySourceName = new HashMap<>();
    }

    private File lookupJarFile() {
    return new File(repository + "/" + groupId.replace(".", "/") + "/jooq/" + version + "/jooq-" + version + ".jar");
    }

    private void init() throws IOException {
    if (!init) {
    init = true;

    try (JarFile j = new JarFile(jarFile)) {
    j.stream()
    .filter(e -> e.getName().endsWith(".class"))
    .map(e -> new ClassReader(readEntry(j, e)))
    .map(c -> {
    ClassNode node = new ClassNode();
    c.accept(node, 0);
    return node;
    })
    .filter(c -> (c.access & Opcodes.ACC_PUBLIC) != 0)
    .filter(c -> Seq.seq(c.visibleAnnotations).noneMatch(a -> "Lorg/jooq/Internal;".equals(a.desc)))
    .filter(c -> !P_TUPLE_TYPE.matcher(c.name).matches())
    .forEach(c -> {
    classes.put(c.name, c);
    classesBySourceName.put(sourceName(c.name), c);
    });
    }
    }
    }

    private byte[] readEntry(JarFile jar, JarEntry e) {
    try (InputStream is = jar.getInputStream(e)) {
    return IOUtils.toByteArray(is);
    }
    catch (IOException ex) {
    throw new RuntimeException(ex);
    }
    }

    private boolean proAnnotationAvailable() {
    if (proAnnotationAvailable == null)
    proAnnotationAvailable = classesBySourceName.containsKey("org.jooq.Pro");

    return proAnnotationAvailable;
    }

    @Override
    public String toString() {
    return version;
    }
    }

    enum Modification {
    added,
    removed,
    pulledup("pulled up"),
    pusheddown("pushed down"),
    contravariance("contravariance"),
    covariance("covariance"),
    deprecated,

    ;

    final String label;

    private Modification() {
    this(null);
    }

    private Modification(String label) {
    this.label = label == null ? name() : label;
    }

    @Override
    public String toString() {
    return label;
    }
    }
    }