SLING-8737 - Add support for lazily-evaluated bindings

* added a lazy bindings implementation in LazyBindings and made SlingBindings
extend it
* added a LazyBindings.Supplier marker interface, so that the SlingBindings
map stays 100% backwards-compatible when it comes to handling known objects
diff --git a/src/main/java/org/apache/sling/api/scripting/LazyBindings.java b/src/main/java/org/apache/sling/api/scripting/LazyBindings.java
new file mode 100644
index 0000000..8bbe55b
--- /dev/null
+++ b/src/main/java/org/apache/sling/api/scripting/LazyBindings.java
@@ -0,0 +1,183 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.api.scripting;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import javax.script.Bindings;
+
+import org.jetbrains.annotations.NotNull;
+import org.osgi.annotation.versioning.ConsumerType;
+
+/**
+ * <p>
+ * The {@code LazyBindings} wraps another map and dynamically provides entries for the wrapped map through a map of {@link
+ * LazyBindings.Supplier}s.
+ * </p>
+ * <p>
+ * When {@link #get(Object)} is called with a {@code key} that's not present in the wrapped map, then the {@link LazyBindings.Supplier}s map
+ * will be queried and, if an entry exists for that key, the {@link LazyBindings.Supplier}-generated value will be used to populate the
+ * wrapped map.
+ * </p>
+ * <p>
+ * While the {@link #keySet()} and {@link #containsKey(Object)} will also check the keys present in the {@link LazyBindings.Supplier}s map,
+ * all other methods (e.g. {@link #values()}, {@link #containsValue(Object)}) will only deal with the contents of the wrapped map.
+ * <p>
+ * {@link #entrySet()} will however return a merged view of both the {@link LazyBindings.Supplier}s and the wrapped map, so that copies to
+ * other {@code LazyBindings} maps preserve the functionality of having lazily-evaluated bindings.</p>
+ * <p>
+ * This class <b>does not provide any thread-safety guarantees</b>. If {@code this} {@code Bindings} map needs to be used in a concurrent
+ * setup it's the responsibility of the caller to synchronize access. The simplest way would be to wrap it through {@link
+ * Collections#synchronizedMap(Map)}.
+ * </p>
+ */
+@ConsumerType
+public class LazyBindings extends HashMap<String, Object> implements Bindings {
+
+    private final Map<String, LazyBindings.Supplier> suppliers;
+
+    public LazyBindings() {
+        this(new HashMap<>(), Collections.emptyMap());
+    }
+
+    public LazyBindings(Map<String, LazyBindings.Supplier> suppliers) {
+        this(suppliers, Collections.emptyMap());
+    }
+
+    public LazyBindings(Map<String, LazyBindings.Supplier> suppliers, Map<String, Object> wrapped) {
+        super(wrapped);
+        this.suppliers = suppliers;
+    }
+
+
+    @Override
+    public Object put(String key, Object value) {
+        Object previous = super.get(key);
+        if (value instanceof LazyBindings.Supplier) {
+            suppliers.put(key, (LazyBindings.Supplier) value);
+        } else {
+            super.put(key, value);
+        }
+        return previous;
+    }
+
+    @Override
+    public void putAll(Map<? extends String, ?> toMerge) {
+        for (Entry<? extends String, ?> entry : toMerge.entrySet()) {
+            put(entry.getKey(), entry.getValue());
+        }
+    }
+
+    @Override
+    public void clear() {
+        super.clear();
+        suppliers.clear();
+    }
+
+    @NotNull
+    @Override
+    public Set<String> keySet() {
+        Set<String> keySet = new HashSet<>(super.keySet());
+        if (!suppliers.isEmpty()) {
+            keySet.addAll(suppliers.keySet());
+        }
+        return Collections.unmodifiableSet(keySet);
+    }
+
+    @NotNull
+    @Override
+    public Collection<Object> values() {
+        return super.values();
+    }
+
+    @NotNull
+    @Override
+    public Set<Entry<String, Object>> entrySet() {
+        HashSet<Entry<String, Object>> entrySet = new HashSet<>(super.entrySet());
+        for (Map.Entry supplierEntry : suppliers.entrySet()) {
+            entrySet.add(supplierEntry);
+        }
+        return Collections.unmodifiableSet(entrySet);
+    }
+
+    @Override
+    public int size() {
+        Set<String> keys = new HashSet<>(super.keySet());
+        keys.addAll(suppliers.keySet());
+        return keys.size();
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return size() == 0;
+    }
+
+    @Override
+    public boolean containsKey(Object key) {
+        return super.containsKey(key) || suppliers.containsKey(key);
+    }
+
+    @Override
+    public boolean containsValue(Object value) {
+        return super.containsValue(value);
+    }
+
+    @Override
+    public Object get(Object key) {
+        String k = key.toString();
+        if (!super.containsKey(k) && suppliers.containsKey(k)) {
+            Object value = suppliers.get(k).get();
+            super.put(k, value);
+            suppliers.remove(k);
+        }
+        return super.get(key);
+    }
+
+    @Override
+    public Object remove(Object key) {
+        Object previous = super.remove(key);
+        if (previous == null) {
+            LazyBindings.Supplier supplier = suppliers.remove(key);
+            if (supplier != null) {
+                return supplier.get();
+            }
+        }
+        return previous;
+    }
+
+    @Override
+    public Object getOrDefault(Object key, Object defaultValue) {
+        Object result = get(key);
+        if (result == null) {
+            return defaultValue;
+        }
+        return result;
+    }
+    /**
+     * This marker interface should be used for suppliers which should be unwrapped when used as values stored in a {@link LazyBindings} map.
+     */
+    @ConsumerType
+    @FunctionalInterface
+    public interface Supplier extends java.util.function.Supplier {}
+}
diff --git a/src/main/java/org/apache/sling/api/scripting/SlingBindings.java b/src/main/java/org/apache/sling/api/scripting/SlingBindings.java
index 4212390..bdc32f7 100644
--- a/src/main/java/org/apache/sling/api/scripting/SlingBindings.java
+++ b/src/main/java/org/apache/sling/api/scripting/SlingBindings.java
@@ -20,14 +20,12 @@
 
 import java.io.PrintWriter;
 import java.io.Reader;
-import java.util.HashMap;
-
-import org.jetbrains.annotations.Nullable;
 
 import org.apache.sling.api.SlingHttpServletRequest;
 import org.apache.sling.api.SlingHttpServletResponse;
 import org.apache.sling.api.resource.Resource;
 import org.apache.sling.api.resource.ResourceResolver;
+import org.jetbrains.annotations.Nullable;
 import org.slf4j.Logger;
 
 /**
@@ -36,7 +34,7 @@
  * which <em>MUST</em> or <em>MAY</em> be provided for the script execution.
  * Other variables may be define as callers see fit.
  */
-public class SlingBindings extends HashMap<String, Object> {
+public class SlingBindings extends LazyBindings {
 
     private static final long serialVersionUID = 209505693646323450L;
 
diff --git a/src/main/java/org/apache/sling/api/scripting/package-info.java b/src/main/java/org/apache/sling/api/scripting/package-info.java
index 8cd75db..9e31fd1 100644
--- a/src/main/java/org/apache/sling/api/scripting/package-info.java
+++ b/src/main/java/org/apache/sling/api/scripting/package-info.java
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-@Version("2.3.3")
+@Version("2.4.0")
 package org.apache.sling.api.scripting;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/src/test/java/org/apache/sling/api/scripting/LazyBindingsTest.java b/src/test/java/org/apache/sling/api/scripting/LazyBindingsTest.java
new file mode 100644
index 0000000..71c2894
--- /dev/null
+++ b/src/test/java/org/apache/sling/api/scripting/LazyBindingsTest.java
@@ -0,0 +1,206 @@
+/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ ~ 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.sling.api.scripting;
+
+import java.util.AbstractMap;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class LazyBindingsTest {
+    private static final String THE_QUESTION = "What is The Answer to the Ultimate Question of Life, The Universe, and Everything?";
+    private static final int THE_ANSWER = 42;
+
+    private Set<String> usedSuppliers;
+    private LazyBindings lazyBindings;
+
+    private final LazyBindings.Supplier supplier = new LazyBindings.Supplier() {
+        @Override
+        public Object get() {
+            usedSuppliers.add(THE_QUESTION);
+            return THE_ANSWER;
+        }
+    };
+
+    @Before
+    public void setUp() {
+        usedSuppliers = new HashSet<>();
+        final Map<String, LazyBindings.Supplier> supplierMap = new HashMap<>();
+        supplierMap.put(THE_QUESTION, supplier);
+        lazyBindings = new LazyBindings(supplierMap);
+    }
+
+    @After
+    public void tearDown() {
+        usedSuppliers = null;
+        lazyBindings = null;
+    }
+
+    @Test
+    public void testGet() {
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        assertEquals(THE_ANSWER, lazyBindings.get(THE_QUESTION));
+        assertTrue(usedSuppliers.contains(THE_QUESTION));
+        assertNull(lazyBindings.get("none"));
+    }
+
+    @Test
+    public void testRemove() {
+        lazyBindings.put("a", 0);
+        assertNull(lazyBindings.remove("null"));
+        assertEquals(0, lazyBindings.remove("a"));
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        assertEquals(THE_ANSWER, lazyBindings.remove(THE_QUESTION));
+        assertTrue(usedSuppliers.contains(THE_QUESTION));
+    }
+
+    @Test
+    public void testPut() {
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        Object supplierProvidedValueReplacement = lazyBindings.put(THE_QUESTION, 43);
+        assertNull(supplierProvidedValueReplacement);
+        assertEquals(43, lazyBindings.get(THE_QUESTION));
+
+        lazyBindings.put("putSupplier", (LazyBindings.Supplier) () -> {
+            usedSuppliers.add("putSupplier");
+            return "putSupplierValue";
+        });
+        assertFalse(usedSuppliers.contains("putSupplier"));
+        assertTrue(lazyBindings.containsKey("putSupplier"));
+        assertEquals("putSupplierValue", lazyBindings.get("putSupplier"));
+        assertTrue(usedSuppliers.contains("putSupplier"));
+    }
+
+    @Test
+    public void testPutAll() {
+        Map<String, Object> toMerge = new HashMap<>();
+        toMerge.put(THE_QUESTION, (LazyBindings.Supplier) () -> {
+            usedSuppliers.add(THE_QUESTION);
+            return THE_ANSWER;
+        });
+        toMerge.put("b", 1);
+        toMerge.put("c", 2);
+        lazyBindings.put("a", 0);
+        lazyBindings.put("putSupplier", (LazyBindings.Supplier) () -> {
+            usedSuppliers.add("putSupplier");
+            return "putSupplierValue";
+        });
+        lazyBindings.putAll(toMerge);
+        Set<String> keys = new HashSet<>(Arrays.asList(THE_QUESTION, "a", "b", "c", "putSupplier"));
+        assertEquals(keys, lazyBindings.keySet());
+        assertEquals(THE_ANSWER, lazyBindings.get(THE_QUESTION));
+        assertTrue(usedSuppliers.contains(THE_QUESTION));
+        assertEquals("putSupplierValue", lazyBindings.get("putSupplier"));
+        assertTrue(usedSuppliers.contains("putSupplier"));
+        assertEquals(2, lazyBindings.get("c"));
+    }
+
+    @Test
+    public void testClearSizeEmpty() {
+        lazyBindings.put("a", 0);
+        assertEquals(2, lazyBindings.size());
+        assertFalse(lazyBindings.isEmpty());
+        lazyBindings.clear();
+        assertEquals(0, lazyBindings.size());
+        assertTrue(lazyBindings.isEmpty());
+    }
+
+    @Test
+    public void testLazyContainsKey() {
+        lazyBindings.put("a", 0);
+        assertTrue(lazyBindings.containsKey(THE_QUESTION));
+        assertTrue(lazyBindings.containsKey("a"));
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+    }
+
+    @Test
+    public void testContainsValue() {
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        assertFalse(lazyBindings.containsValue(THE_ANSWER));
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        lazyBindings.put("a", 0);
+        assertTrue(lazyBindings.containsValue(0));
+    }
+
+    @Test
+    public void testEntrySet() {
+        lazyBindings.put("a", 0);
+        Set<Map.Entry<String, Object>> expectedEntrySet = new HashSet<>();
+        expectedEntrySet.add(new AbstractMap.SimpleEntry<>("a", 0));
+        expectedEntrySet.add(new AbstractMap.SimpleEntry<>(THE_QUESTION, supplier));
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        assertEquals(expectedEntrySet, lazyBindings.entrySet());
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+    }
+
+    @Test
+    public void testKeySet() {
+        lazyBindings.put("a", 0);
+        assertEquals(new HashSet<>(Arrays.asList(THE_QUESTION, "a")), lazyBindings.keySet());
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+    }
+
+    @Test
+    public void testValues() {
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        Collection<Object> values = lazyBindings.values();
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        assertEquals(0, values.size());
+        lazyBindings.put("a", 0);
+        Set<Object> expectedValues = new HashSet<>();
+        expectedValues.add(0);
+        assertEquals(expectedValues, new HashSet<>(lazyBindings.values()));
+    }
+
+    @Test
+    public void testGetOrDefault() {
+        lazyBindings.put("a", 0);
+        assertEquals(0, lazyBindings.getOrDefault("a", 1));
+        assertFalse(usedSuppliers.contains(THE_QUESTION));
+        assertEquals(THE_ANSWER, lazyBindings.getOrDefault(THE_QUESTION, THE_ANSWER + 1));
+        assertTrue(usedSuppliers.contains(THE_QUESTION));
+        assertEquals(1, lazyBindings.getOrDefault("b", 1));
+    }
+
+    @Test
+    public void testThatNormalSuppliersAreNotUnwrapped() {
+        final String supplierName = "regularSupplier";
+        Supplier<Object> regularSupplier = () -> {
+            usedSuppliers.add(supplierName);
+            return 0;
+        };
+        lazyBindings.put(supplierName, regularSupplier);
+        assertEquals(regularSupplier, lazyBindings.get(supplierName));
+        assertFalse(usedSuppliers.contains(supplierName));
+    }
+
+}