BindingSnapshot.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 */
package org.apache.maven.di.impl;

import java.lang.annotation.Annotation;
import java.lang.reflect.Executable;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.GenericDeclaration;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.lang.reflect.WildcardType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import org.apache.maven.di.Key;
import org.apache.maven.di.Scope;

/** Detached diagnostic metadata; capturing it does not compile or provision bindings. */
final class BindingSnapshot {
    private static final String NONE = "<none>";
    private static final String NO_CANDIDATES = "<no candidates>";
    private static final String ADDED = "+ ";
    private static final String REMOVED = "- ";
    private static final String CHANGED = "~ ";
    private final long revision;
    private final boolean ambiguous;
    private final List<KeyBindings> keys;
    private final List<ScopeBinding> scopes;
    private final Map<String, String> loaders;

    private BindingSnapshot(
            long revision,
            boolean ambiguous,
            List<KeyBindings> keys,
            List<ScopeBinding> scopes,
            Map<String, String> loaders) {
        this.revision = revision;
        this.ambiguous = ambiguous;
        this.keys = List.copyOf(keys);
        this.scopes = List.copyOf(scopes);
        this.loaders = Collections.unmodifiableMap(new LinkedHashMap<>(loaders));
    }

    static BindingSnapshot capture(
            Map<Key<?>, Set<Binding<?>>> bindings,
            Map<Class<? extends Annotation>, Supplier<Scope>> scopes,
            DiagnosticIdentity identities,
            boolean ambiguous,
            long revision) {
        Capture format = new Capture(identities);
        List<KeyBindings> keys = new ArrayList<>();
        Map<Binding<?>, Candidate> descriptions = new IdentityHashMap<>();
        Comparator<Binding<?>> candidateOrder = Binding.getPriorityComparator().thenComparing(Binding::identityString);
        var registrations = bindings.entrySet().stream()
                .filter(entry -> !ambiguous || entry.getValue().size() != 1)
                .map(entry -> Map.entry(
                        Capture.key(entry.getKey(), Binding::identityString).plain(), entry))
                .sorted(Map.Entry.comparingByKey())
                .toList();
        for (var sortEntry : registrations) {
            var registration = sortEntry.getValue();
            KeyText name = format.key(registration.getKey());
            List<Candidate> candidates = registration.getValue().stream()
                    .sorted(candidateOrder)
                    .map(binding -> descriptions.computeIfAbsent(binding, format::describe))
                    .toList();
            keys.add(new KeyBindings(format.keyIdentity(registration.getKey()), name, candidates));
        }
        List<ScopeBinding> scopeRows = ambiguous
                ? List.of()
                : scopes.entrySet().stream()
                        .sorted(Comparator.comparing(entry -> Capture.type(entry.getKey(), Binding::identityString)))
                        .map(entry -> new ScopeBinding(
                                identities.token(entry.getKey()),
                                format.type(entry.getKey()),
                                identities.token(entry.getValue()),
                                Binding.identityString(entry.getValue())))
                        .toList();
        return new BindingSnapshot(revision, ambiguous, keys, scopeRows, identities.loaders());
    }

    String render(Object injector, String reason, boolean partial, BindingSnapshot previous, BindingStyler styler) {
        List<String> blocks = new ArrayList<>();
        if (previous == null) {
            keys.forEach(key -> blocks.add(renderKey(key, "", styler)));
            if (!scopes.isEmpty()) {
                blocks.add(renderScopes(scopes, "", styler));
            }
        } else {
            Map<List<?>, KeyBindings> oldKeys = new LinkedHashMap<>();
            previous.keys.forEach(key -> oldKeys.put(key.identity, key));
            for (KeyBindings key : keys) {
                KeyBindings old = oldKeys.remove(key.identity);
                String changes = old == null ? renderKey(key, ADDED, styler) : renderChanges(old, key, styler);
                if (!changes.isEmpty()) {
                    blocks.add(changes);
                }
            }
            oldKeys.values().forEach(key -> blocks.add(renderKey(key, REMOVED, styler)));
            Map<Long, ScopeBinding> oldScopes = new LinkedHashMap<>();
            previous.scopes.forEach(scope -> oldScopes.put(scope.identity, scope));
            List<ScopeBinding> removed = new ArrayList<>();
            List<ScopeBinding> added = new ArrayList<>();
            for (ScopeBinding scope : scopes) {
                ScopeBinding old = oldScopes.remove(scope.identity);
                if (!scope.equals(old)) {
                    if (old != null) {
                        removed.add(old);
                    }
                    added.add(scope);
                }
            }
            removed.addAll(oldScopes.values());
            if (!removed.isEmpty()) {
                blocks.add(renderScopes(removed, REMOVED, styler));
            }
            if (!added.isEmpty()) {
                blocks.add(renderScopes(added, ADDED, styler));
            }
            if (blocks.isEmpty() && !partial) {
                return null;
            }
        }
        if (!loaders.isEmpty()) {
            blocks.add("  " + styler.header("class loaders:") + "\n"
                    + loaders.entrySet().stream()
                            .map(entry -> "    " + entry.getKey() + ": " + entry.getValue())
                            .collect(Collectors.joining("\n")));
        }
        String registry = "Maven DI native registry " + Binding.identityString(injector);
        String revisions = previous == null ? Long.toString(revision) : previous.revision + "->" + revision;
        String header = registry + " revision=" + revisions + " (" + reason
                + (previous == null ? "" : ", delta") + (ambiguous ? ", ambiguous" : "")
                + (partial ? ", partial" : "") + ") {";
        return String.join("\n", styler.header(header), String.join("\n\n", blocks), styler.header("} " + registry));
    }

    private static String renderChanges(KeyBindings before, KeyBindings after, BindingStyler styler) {
        Map<Long, Candidate> oldCandidates = new LinkedHashMap<>();
        before.candidates.forEach(candidate -> oldCandidates.put(candidate.identity, candidate));
        List<String> changes = new ArrayList<>();
        for (Candidate candidate : after.candidates) {
            Candidate old = oldCandidates.remove(candidate.identity);
            if (!candidate.equals(old)) {
                changes.add(renderCandidate(candidate, old == null ? ADDED : CHANGED, styler));
            }
        }
        oldCandidates.values().forEach(candidate -> changes.add(renderCandidate(candidate, REMOVED, styler)));
        if (changes.isEmpty()) {
            return "";
        }
        if (after.candidates.isEmpty()) {
            changes.add("    " + NO_CANDIDATES);
        }
        return "  " + styler.header(CHANGED + "key:") + " " + after.name.render(styler) + "\n"
                + String.join("\n\n", changes);
    }

    private static String renderKey(KeyBindings key, String marker, BindingStyler styler) {
        String candidates = key.candidates.isEmpty()
                ? "    " + styler.header(marker + NO_CANDIDATES)
                : key.candidates.stream()
                        .map(candidate -> renderCandidate(candidate, marker, styler))
                        .collect(Collectors.joining("\n\n"));
        return "  " + styler.header(marker + "key:") + " " + key.name.render(styler) + "\n" + candidates;
    }

    private static String renderScopes(List<ScopeBinding> scopes, String marker, BindingStyler styler) {
        return "  " + styler.header(marker + "scopes:") + "\n"
                + scopes.stream()
                        .map(scope -> "    " + styler.scope(scope.type, true) + "\n      supplier: " + scope.supplier)
                        .collect(Collectors.joining("\n"));
    }

    private static String renderCandidate(Candidate candidate, String marker, BindingStyler styler) {
        StringJoiner lines = new StringJoiner("\n");
        lines.add("    " + styler.header(marker + "candidate:") + " "
                + (candidate.original == null ? NONE : candidate.original.render(styler)));
        lines.add("      priority: " + styler.priority(Integer.toString(candidate.priority), candidate.priority != 0));
        lines.add("      scope: "
                + styler.scope(candidate.scope == null ? NONE : candidate.scope, candidate.scope != null));
        if (candidate.source.size() == 1) {
            lines.add("      source: " + escape(candidate.source.get(0)));
        } else if (!candidate.source.isEmpty()) {
            lines.add("      source:");
            candidate.source.forEach(line -> lines.add("        " + escape(line)));
        }
        if (candidate.dependencies.isEmpty()) {
            lines.add("      dependencies: " + NONE);
        } else {
            lines.add("      dependencies:");
            candidate.dependencies.forEach(dependency -> lines.add("        - "
                    + (dependency.optional ? styler.optionalDependency("?") : "") + dependency.key.render(styler)));
        }
        return lines.toString();
    }

    private record KeyText(String qualifier, String type) {
        String plain() {
            return (qualifier.isEmpty() ? "" : qualifier + " ") + type;
        }

        String render(BindingStyler styler) {
            return (qualifier.isEmpty() ? "" : styler.qualifier(qualifier) + " ") + styler.key(type);
        }
    }

    private record KeyBindings(List<?> identity, KeyText name, List<Candidate> candidates) {}

    private record Candidate(
            long identity,
            List<?> originalIdentity,
            KeyText original,
            int priority,
            String scope,
            List<String> source,
            Set<DependencyText> dependencies) {}

    private record DependencyText(List<?> identity, KeyText key, boolean optional) {
        String plain() {
            return (optional ? "?" : "") + key.plain();
        }
    }

    private record ScopeBinding(long identity, String type, long supplierIdentity, String supplier) {}

    /** Snapshots retain detached metadata, not registry objects. */
    private static final class Capture {
        private final DiagnosticIdentity identities;

        private Capture(DiagnosticIdentity identities) {
            this.identities = identities;
        }

        private Candidate describe(Binding<?> binding) {
            // Assign loader IDs in full-key order before sorting the displayed dependencies.
            List<DependencyText> dependencies = binding.getDependencies().stream()
                    .sorted(Comparator.comparing(dependency ->
                            key(dependency.key(), Binding::identityString).plain()))
                    .map(dependency -> new DependencyText(
                            keyIdentity(dependency.key()), key(dependency.key()), dependency.optional()))
                    .sorted(Comparator.comparing(DependencyText::plain))
                    .toList();
            List<String> source = binding.getDiagnosticSourceLines(this::sourceLines);
            return new Candidate(
                    identities.token(binding),
                    binding.getOriginalKey() == null ? List.of() : keyIdentity(binding.getOriginalKey()),
                    binding.getOriginalKey() == null ? null : key(binding.getOriginalKey()),
                    binding.getPriority(),
                    binding.getScope() == null ? null : type(binding.getScope().annotationType()),
                    List.copyOf(source),
                    Collections.unmodifiableSet(new LinkedHashSet<>(dependencies)));
        }

        private List<String> sourceLines(Member member) {
            String loaderReference =
                    " [loader=" + loader(member.getDeclaringClass().getClassLoader()) + "]";
            if (!(member instanceof Executable executable)) {
                return List.of(member + loaderReference);
            }
            List<String> lines = new ArrayList<>();
            int mask = member instanceof Method ? Modifier.methodModifiers() : Modifier.constructorModifiers();
            String modifiers = Modifier.toString(member.getModifiers() & mask);
            String prefix = modifiers.isEmpty() ? "" : modifiers + " ";
            String name = member.getDeclaringClass().getName();
            if (member instanceof Method method) {
                lines.add(prefix + (method.isDefault() ? "default " : "") + type(method.getReturnType()));
                prefix = "";
                name += "." + member.getName();
            }
            Class<?>[] parameters = executable.getParameterTypes();
            if (parameters.length == 0) {
                lines.add(prefix + name + "()" + loaderReference);
            } else {
                lines.add(prefix + name + "(");
                for (int i = 0; i < parameters.length; i++) {
                    lines.add("  " + type(parameters[i]) + (i + 1 < parameters.length ? "," : ""));
                }
                lines.add(")" + loaderReference);
            }
            for (Class<?> exception : executable.getExceptionTypes()) {
                lines.add("throws " + type(exception));
            }
            return List.copyOf(lines);
        }

        private String loader(ClassLoader loader) {
            return identities.loader(loader);
        }

        private List<?> keyIdentity(Key<?> key) {
            Object qualifier = key.getQualifier();
            Object qualifierIdentity = qualifier == null
                    ? List.of()
                    : qualifier instanceof String name
                            ? List.of("name", name)
                            : List.of("object", identities.token(qualifier));
            return List.of(qualifierIdentity, typeIdentity(key.getType()));
        }

        private Object typeIdentity(Type type) {
            if (type instanceof Class<?> clazz) {
                return List.of("class", identities.token(clazz));
            }
            if (type instanceof ParameterizedType parameterized) {
                return List.of(
                        "parameterized",
                        parameterized.getOwnerType() == null ? List.of() : typeIdentity(parameterized.getOwnerType()),
                        typeIdentity(parameterized.getRawType()),
                        typeIdentities(parameterized.getActualTypeArguments()));
            }
            if (type instanceof GenericArrayType array) {
                return List.of("array", typeIdentity(array.getGenericComponentType()));
            }
            if (type instanceof TypeVariable<?> variable) {
                return List.of("variable", declarationIdentity(variable.getGenericDeclaration()), variable.getName());
            }
            if (type instanceof WildcardType wildcard) {
                return List.of(
                        "wildcard",
                        typeIdentities(wildcard.getLowerBounds()),
                        typeIdentities(wildcard.getUpperBounds()));
            }
            return List.of("opaque", identities.token(type));
        }

        private List<?> typeIdentities(Type[] types) {
            return Arrays.stream(types).map(this::typeIdentity).toList();
        }

        private Object declarationIdentity(GenericDeclaration declaration) {
            if (declaration instanceof Class<?> clazz) {
                return typeIdentity(clazz);
            }
            if (declaration instanceof Executable executable) {
                return List.of(
                        executable instanceof Method ? "method" : "constructor",
                        identities.token(executable.getDeclaringClass()),
                        executable.getName(),
                        typeIdentities(executable.getParameterTypes()),
                        executable instanceof Method method ? typeIdentity(method.getReturnType()) : List.of());
            }
            return List.of("declaration", identities.token(declaration));
        }

        private KeyText key(Key<?> key) {
            return key(key, this::loader);
        }

        private String type(Type type) {
            return type(type, this::loader);
        }

        private static KeyText key(Key<?> key, Function<ClassLoader, String> loader) {
            Object qualifier = key.getQualifier();
            String prefix;
            if (qualifier instanceof String name) {
                prefix = "@Named(\"" + escape(name) + "\")";
            } else if (qualifier instanceof Class<?> annotationType) {
                prefix = "@" + type(annotationType, loader);
            } else if (qualifier instanceof Annotation annotation) {
                prefix = "@" + type(annotation.annotationType(), loader) + " [qualifier="
                        + Binding.identityString(annotation) + "]";
            } else if (qualifier != null) {
                prefix = "@" + Binding.identityString(qualifier);
            } else {
                prefix = "";
            }
            return new KeyText(prefix, type(key.getType(), loader));
        }

        private static String type(Type type, Function<ClassLoader, String> loader) {
            if (type instanceof Class<?> clazz) {
                return clazz.isArray()
                        ? type(clazz.getComponentType(), loader) + "[]"
                        : classType(clazz, clazz.getName(), loader);
            }
            if (type instanceof ParameterizedType parameterized) {
                Type owner = parameterized.getOwnerType();
                Type raw = parameterized.getRawType();
                String name = owner != null && raw instanceof Class<?> clazz
                        ? classType(clazz, clazz.getSimpleName(), loader)
                        : type(raw, loader);
                return (owner == null ? "" : type(owner, loader) + ".") + name + "<"
                        + Arrays.stream(parameterized.getActualTypeArguments())
                                .map(argument -> type(argument, loader))
                                .collect(Collectors.joining(", "))
                        + ">";
            }
            if (type instanceof GenericArrayType array) {
                return type(array.getGenericComponentType(), loader) + "[]";
            }
            if (type instanceof TypeVariable<?> variable) {
                return variable.getName();
            }
            if (type instanceof WildcardType wildcard) {
                Type[] lowerBounds = wildcard.getLowerBounds();
                if (lowerBounds.length > 0) {
                    return "? super "
                            + Arrays.stream(lowerBounds)
                                    .map(argument -> type(argument, loader))
                                    .collect(Collectors.joining(" & "));
                }
                Type[] upperBounds = wildcard.getUpperBounds();
                if (upperBounds.length == 1 && upperBounds[0] == Object.class) {
                    return "?";
                }
                return "? extends "
                        + Arrays.stream(upperBounds)
                                .map(argument -> type(argument, loader))
                                .collect(Collectors.joining(" & "));
            }
            return Binding.identityString(type);
        }

        private static String classType(Class<?> clazz, String name, Function<ClassLoader, String> loader) {
            return name + " [loader=" + loader.apply(clazz.getClassLoader()) + "]";
        }
    }

    private static String escape(String value) {
        StringBuilder result = new StringBuilder(value.length());
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            if (c == '\\' || c == '"') {
                result.append('\\').append(c);
            } else if (Character.isISOControl(c)) {
                result.append(String.format("\\u%04x", (int) c));
            } else {
                result.append(c);
            }
        }
        return result.toString();
    }
}