SLING-10188 - Reduce the number of service registrations for inheriting BundledScriptServlets

* merge capabilities that provide only extend info with other capabilities with the same
resource type
diff --git a/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledRenderUnitCapabilityImpl.java b/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledRenderUnitCapabilityImpl.java
index 645d83e..b54c47d 100644
--- a/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledRenderUnitCapabilityImpl.java
+++ b/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledRenderUnitCapabilityImpl.java
@@ -26,6 +26,7 @@
 import java.util.Objects;
 import java.util.Set;
 
+import org.apache.commons.lang3.StringUtils;
 import org.apache.sling.api.servlets.ServletResolverConstants;
 import org.apache.sling.commons.osgi.PropertiesUtil;
 import org.apache.sling.scripting.spi.bundle.BundledRenderUnitCapability;
@@ -131,6 +132,37 @@
         return false;
     }
 
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder(BundledRenderUnitCapability.class.getSimpleName()).append("[");
+        if (!resourceTypes.isEmpty()) {
+            sb.append(ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES).append("=").append(resourceTypes);
+        }
+        if (!selectors.isEmpty()) {
+            sb.append("; ").append(ServletResolverConstants.SLING_SERVLET_SELECTORS).append("=").append(selectors);
+        }
+        if (StringUtils.isNotEmpty(extension)) {
+            sb.append("; ").append(ServletResolverConstants.SLING_SERVLET_EXTENSIONS).append("=").append(extension);
+        }
+        if (StringUtils.isNotEmpty(method)) {
+            sb.append("; ").append(ServletResolverConstants.SLING_SERVLET_METHODS).append("=").append(method);
+        }
+        if (StringUtils.isNotEmpty(path)) {
+            sb.append("; ").append(ServletResolverConstants.SLING_SERVLET_PATHS).append("=").append(path);
+        }
+        if (StringUtils.isNotEmpty(extendedResourceType)) {
+            sb.append("; ").append(BundledScriptTracker.AT_EXTENDS).append("=").append(extendedResourceType);
+        }
+        if (StringUtils.isNotEmpty(scriptEngineName)) {
+            sb.append("; ").append(BundledScriptTracker.AT_SCRIPT_ENGINE).append("=").append(scriptEngineName);
+        }
+        if (StringUtils.isNotEmpty(scriptExtension)) {
+            sb.append("; ").append(BundledScriptTracker.AT_SCRIPT_EXTENSION).append("=").append(scriptExtension);
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
     public static BundledRenderUnitCapability fromBundleCapability(@NotNull BundleCapability capability) {
         Map<String, Object> attributes = capability.getAttributes();
         Set<ResourceType> resourceTypes = new LinkedHashSet<>();
@@ -156,4 +188,76 @@
                 (String) attributes.get(BundledScriptTracker.AT_SCRIPT_EXTENSION)
         );
     }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    public static class Builder {
+        private Set<ResourceType> resourceTypes;
+        private String path;
+        private List<String> selectors;
+        private String extension;
+        private String method;
+        private String extendedResourceType;
+        private String scriptEngineName;
+        private String scriptExtension;
+
+        public Builder withResourceTypes(@NotNull Set<ResourceType> resourceTypes) {
+            this.resourceTypes = resourceTypes;
+            return this;
+        }
+
+        public Builder withPath(@Nullable String path) {
+            this.path = path;
+            return this;
+        }
+
+        public Builder withSelectors(@NotNull List<String> selectors) {
+            this.selectors = selectors;
+            return this;
+        }
+
+        public Builder withExtension(@Nullable String extension) {
+            this.extension = extension;
+            return this;
+        }
+
+        public Builder withMethod(@Nullable String method) {
+            this.method = method;
+            return this;
+        }
+
+        public Builder withExtendedResourceType(@Nullable String extendedResourceType) {
+            this.extendedResourceType = extendedResourceType;
+            return this;
+        }
+
+        public Builder withScriptEngineName(@Nullable String scriptEngineName) {
+            this.scriptEngineName = scriptEngineName;
+            return this;
+        }
+
+        public Builder withScriptEngineExtension(@Nullable String scriptExtension) {
+            this.scriptExtension = scriptExtension;
+            return this;
+        }
+
+        public Builder fromCapability(@NotNull BundledRenderUnitCapability capability) {
+            this.extendedResourceType = capability.getExtendedResourceType();
+            this.extension = capability.getExtension();
+            this.method = capability.getMethod();
+            this.path = capability.getPath();
+            this.resourceTypes = capability.getResourceTypes();
+            this.scriptEngineName = capability.getScriptEngineName();
+            this.scriptExtension = capability.getScriptExtension();
+            this.selectors = capability.getSelectors();
+            return this;
+        }
+
+        public BundledRenderUnitCapability build() {
+            return new BundledRenderUnitCapabilityImpl(resourceTypes, path, selectors, extension, method, extendedResourceType,
+                    scriptEngineName, scriptExtension);
+        }
+    }
 }
diff --git a/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java b/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java
index 6883088..d785b76 100644
--- a/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java
+++ b/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java
@@ -29,6 +29,7 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Hashtable;
+import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -134,13 +135,18 @@
             LOGGER.debug("Inspecting bundle {} for {} capability.", bundle.getSymbolicName(), NS_SLING_SERVLET);
             List<BundleCapability> capabilities = bundleWiring.getCapabilities(NS_SLING_SERVLET);
             Map<BundleCapability, BundledRenderUnitCapability> cache = new HashMap<>();
+            capabilities.forEach(bundleCapability -> {
+                BundledRenderUnitCapability bundledRenderUnitCapability =
+                        BundledRenderUnitCapabilityImpl.fromBundleCapability(bundleCapability);
+                cache.put(bundleCapability, bundledRenderUnitCapability);
+            });
             Set<TypeProvider> requiresChain = collectRequiresChain(bundleWiring, cache);
             if (!capabilities.isEmpty()) {
-                List<ServiceRegistration<Servlet>> serviceRegistrations = capabilities.stream().flatMap(cap ->
+                Set<BundledRenderUnitCapability> bundledRenderUnitCapabilities = new HashSet<>(cache.values());
+                bundledRenderUnitCapabilities = reduce(bundledRenderUnitCapabilities);
+                List<ServiceRegistration<Servlet>> serviceRegistrations = bundledRenderUnitCapabilities.stream().flatMap(bundledRenderUnitCapability ->
                 {
                     Hashtable<String, Object> properties = new Hashtable<>();
-                    properties.put(Constants.SERVICE_DESCRIPTION, BundledScriptServlet.class.getName() + cap.getAttributes());
-                    BundledRenderUnitCapability bundledRenderUnitCapability = cache.computeIfAbsent(cap, BundledRenderUnitCapabilityImpl::fromBundleCapability);
                     BundledRenderUnit executable = null;
                     TypeProvider baseTypeProvider = new TypeProviderImpl(bundledRenderUnitCapability, bundle);
                     LinkedHashSet<TypeProvider> inheritanceChain = new LinkedHashSet<>();
@@ -242,11 +248,13 @@
                         }
                         properties.put(ServletResolverConstants.SLING_SERVLET_NAME,
                                 String.format("%s (%s)", BundledScriptServlet.class.getSimpleName(), executablePath));
+                        properties.put(Constants.SERVICE_DESCRIPTION,
+                                BundledScriptServlet.class.getName() + "{" + bundledRenderUnitCapability + "}");
                         regs.add(
                             register(bundle.getBundleContext(), new BundledScriptServlet(inheritanceChain, executable), properties)
                         );
                     } else {
-                        LOGGER.warn(String.format("Unable to locate an executable for capability %s.", cap));
+                        LOGGER.warn(String.format("Unable to locate an executable for capability %s.", bundledRenderUnitCapability.toString()));
                     }
 
                     return regs.stream();
@@ -589,4 +597,52 @@
         }
         return requiresChain;
     }
+
+    /**
+     * Given a {@code capabilities} set, this method will merge a capability providing a non-null {@link
+     * BundledRenderUnitCapability#getExtendedResourceType()} and just a resource type information with the other capabilities describing
+     * the same resource type.
+     *
+     * @param capabilities the original capabilities set
+     * @return a new set with merged capabilities or the original set, if no merges had to be performed
+     */
+    private Set<BundledRenderUnitCapability> reduce(Set<BundledRenderUnitCapability> capabilities) {
+        Set<BundledRenderUnitCapability> extenders =
+                capabilities.stream().filter(cap -> cap.getExtendedResourceType() != null && !cap.getResourceTypes().isEmpty() &&
+                        cap.getSelectors().isEmpty() && cap.getMethod() == null && cap.getExtension() == null && cap.getScriptEngineName() == null).collect(Collectors.toSet());
+        if (extenders.isEmpty()) {
+            return capabilities;
+        }
+        Set<BundledRenderUnitCapability> originalCapabilities = new HashSet<>(capabilities);
+        Set<BundledRenderUnitCapability> newSet = new HashSet<>();
+        originalCapabilities.removeAll(extenders);
+        if (originalCapabilities.isEmpty()) {
+            return extenders;
+        }
+        Iterator<BundledRenderUnitCapability> extendersIterator = extenders.iterator();
+        while (extendersIterator.hasNext()) {
+            BundledRenderUnitCapability extender = extendersIterator.next();
+            Iterator<BundledRenderUnitCapability> mergeCandidates = originalCapabilities.iterator();
+            boolean processedExtender = false;
+            while (mergeCandidates.hasNext()) {
+                BundledRenderUnitCapability mergeCandidate = mergeCandidates.next();
+                if (extender.getResourceTypes().equals(mergeCandidate.getResourceTypes())) {
+                    BundledRenderUnitCapability mergedCapability =
+                            BundledRenderUnitCapabilityImpl.builder()
+                                    .fromCapability(mergeCandidate)
+                                    .withExtendedResourceType(extender.getExtendedResourceType()).build();
+                    newSet.add(mergedCapability);
+                    mergeCandidates.remove();
+                    processedExtender = true;
+                }
+            }
+            if (processedExtender) {
+                extendersIterator.remove();
+            }
+        }
+        // add extenders for which we couldn't merge their properties
+        newSet.addAll(extenders);
+        newSet.addAll(originalCapabilities);
+        return newSet;
+    }
 }