diff --git a/lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt b/lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt
new file mode 100644
index 00000000..fbf880e2
--- /dev/null
+++ b/lib/sdk/server-ai/THIRD-PARTY-NOTICES.txt
@@ -0,0 +1,43 @@
+This product includes vendored third-party source code. The relevant licenses
+and copyright notices are reproduced below.
+
+================================================================================
+JMustache (jmustache)
+--------------------------------------------------------------------------------
+Vendored from: com.samskivert:jmustache:1.15
+Upstream: https://github.com/samskivert/jmustache
+Location: src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/
+
+The JMustache source has been relocated into the internal, non-public package
+com.launchdarkly.sdk.server.ai.internal.mustache and is compiled from source as
+part of this library. Aside from the relocated package declaration and a short
+provenance banner at the top of each file, the source is unmodified from the
+upstream 1.15 release.
+
+License: The (New) BSD License (BSD 3-Clause)
+
+Copyright (c) 2010, Michael Bayne
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+ * The name Michael Bayne may not be used to endorse or promote products
+ derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+================================================================================
diff --git a/lib/sdk/server-ai/build.gradle b/lib/sdk/server-ai/build.gradle
index 1a3acfec..f8a5117d 100644
--- a/lib/sdk/server-ai/build.gradle
+++ b/lib/sdk/server-ai/build.gradle
@@ -45,11 +45,6 @@ ext.versions = [
// The *lowest* version of the base SDK we are compatible with. LDClientInterface
// appears in this library's public signature, so it is exposed as an `api` dependency.
"sdk": "7.14.0"
- // NOTE: the Mustache templating engine (for AI Config message/instruction interpolation) is
- // intentionally not declared here. Per the SDK team's supply-chain guidance we will not link the
- // external com.samskivert:jmustache artifact; the library will be vendored (source copied into an
- // internal, relocated package) along with the Interpolator in AIC-2695, which must land before the
- // v1.0 release (AIC-2666).
]
ext.libraries = [:]
@@ -111,6 +106,13 @@ checkstyle {
configFile = file("${project.rootDir}/checkstyle.xml")
}
+// The vendored Mustache source (com.launchdarkly.sdk.server.ai.internal.mustache) is third-party
+// code kept as close to upstream as possible; do not subject it to our checkstyle conventions.
+def vendoredPackageGlob = "**/com/launchdarkly/sdk/server/ai/internal/mustache/**"
+tasks.named('checkstyleMain') {
+ exclude vendoredPackageGlob
+}
+
idea {
module {
downloadJavadoc = true
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java
new file mode 100644
index 00000000..9fcee28f
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/Interpolator.java
@@ -0,0 +1,133 @@
+package com.launchdarkly.sdk.server.ai.internal;
+
+import com.launchdarkly.sdk.LDContext;
+import com.launchdarkly.sdk.server.ai.internal.mustache.Mustache;
+import com.launchdarkly.sdk.server.ai.internal.mustache.Template;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Renders AI Config message and instruction templates using Mustache, following the cross-SDK
+ * interpolation policy shared with other SDKs:
+ *
+ * No HTML escaping. The escape function is the identity, so {@code {{x}}} and
+ * {@code {{{x}}}} render identically and values are emitted verbatim.
+ * Missing and null variables render as the empty string rather than throwing or
+ * leaving the placeholder in place.
+ * The reserved {@code ldctx} variable is derived from the evaluation context and is
+ * merged in last, so it always overrides any caller-supplied {@code ldctx}. Context
+ * attributes are addressable as {@code {{ldctx.key}}}, {@code {{ldctx.name}}}, and so on.
+ *
+ *
+ * Compiled templates are cached, keyed by template text. The class is thread-safe: the Mustache
+ * compiler is immutable once configured, compiled {@link Template}s are safe for concurrent
+ * execution, and the cache is a {@link ConcurrentHashMap}.
+ *
+ * This class is an internal implementation detail and is not part of the supported API.
+ */
+public final class Interpolator {
+ private final Mustache.Compiler compiler;
+ private final ConcurrentHashMap templateCache = new ConcurrentHashMap<>();
+
+ /**
+ * Creates an interpolator with the cross-SDK escaping policy.
+ */
+ public Interpolator() {
+ // defaultValue("") makes both missing variables and variables that resolve to null render as
+ // the empty string (it sets jmustache's missingIsNull=true and nullValue=""). escapeHTML(false)
+ // emits values verbatim, matching the JS/Python SDKs.
+ this.compiler = Mustache.compiler()
+ .escapeHTML(false)
+ .defaultValue("");
+ }
+
+ /**
+ * Renders a template with the given variables and evaluation context.
+ *
+ * @param template the template text; if {@code null} the result is {@code null}
+ * @param variables caller-supplied variables; may be {@code null}
+ * @param context the evaluation context, exposed to the template as {@code ldctx}; may be
+ * {@code null}
+ * @return the rendered string, or {@code null} if {@code template} is {@code null}
+ */
+ public String interpolate(String template, Map variables, LDContext context) {
+ if (template == null) {
+ return null;
+ }
+ Map merged = new HashMap<>();
+ if (variables != null) {
+ merged.putAll(variables);
+ }
+ // ldctx is added last so it always wins over any caller-supplied "ldctx" entry.
+ merged.put("ldctx", contextToMap(context));
+ return render(template, merged);
+ }
+
+ /**
+ * Renders a template with an already-assembled variable map (no {@code ldctx} injection).
+ *
+ * @param template the template text; if {@code null} the result is {@code null}
+ * @param variables the variables; may be {@code null}
+ * @return the rendered string, or {@code null} if {@code template} is {@code null}
+ */
+ public String interpolate(String template, Map variables) {
+ if (template == null) {
+ return null;
+ }
+ return render(template, variables == null ? new HashMap() : variables);
+ }
+
+ private String render(String template, Map variables) {
+ Template compiled = templateCache.computeIfAbsent(template, compiler::compile);
+ return compiled.execute(variables);
+ }
+
+ /**
+ * Encodes the evaluation context directly into the nested map structure exposed to templates as
+ * {@code ldctx}, without round-tripping through JSON serialization. A single-kind context becomes
+ * a map of its attributes; a multi-kind context becomes
+ * {@code {"kind":"multi", "key":, : {...}}} with one nested map per
+ * individual context.
+ */
+ private static Map contextToMap(LDContext context) {
+ if (context == null || !context.isValid()) {
+ return new HashMap<>();
+ }
+ if (context.isMultiple()) {
+ Map map = new HashMap<>();
+ map.put("kind", "multi");
+ map.put("key", context.getFullyQualifiedKey());
+ int count = context.getIndividualContextCount();
+ for (int i = 0; i < count; i++) {
+ LDContext individual = context.getIndividualContext(i);
+ if (individual != null) {
+ // Mirror LaunchDarkly's standard context JSON: the per-kind objects nested under a
+ // multi-kind context omit "kind" because it is already implied by the property key.
+ map.put(individual.getKind().toString(), singleContextToMap(individual, false));
+ }
+ }
+ return map;
+ }
+ return singleContextToMap(context, true);
+ }
+
+ private static Map singleContextToMap(LDContext context, boolean includeKind) {
+ Map map = new HashMap<>();
+ if (includeKind) {
+ map.put("kind", context.getKind().toString());
+ }
+ map.put("key", context.getKey());
+ if (context.getName() != null) {
+ map.put("name", context.getName());
+ }
+ map.put("anonymous", context.isAnonymous());
+ // Custom attribute values can be arbitrary JSON; convert each LDValue to a plain Java value
+ // (depth-capped) so nested objects/arrays remain addressable from templates.
+ for (String attribute : context.getCustomAttributeNames()) {
+ map.put(attribute, LDValueConverter.toJavaObject(context.getValue(attribute)));
+ }
+ return map;
+ }
+}
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/BasicCollector.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/BasicCollector.java
new file mode 100644
index 00000000..9c9c0080
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/BasicCollector.java
@@ -0,0 +1,180 @@
+// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne).
+// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695).
+// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package
+// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text.
+//
+//
+// JMustache - A Java implementation of the Mustache templating language
+// http://github.com/samskivert/jmustache/blob/master/LICENSE
+
+package com.launchdarkly.sdk.server.ai.internal.mustache;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.NoSuchElementException;
+
+/**
+ * A collector that does not use reflection and can be used with GWT.
+ */
+public abstract class BasicCollector implements Mustache.Collector
+{
+ public Iterator> toIterator (final Object value) {
+ if (value instanceof Iterable>) {
+ return ((Iterable>)value).iterator();
+ }
+ if (value instanceof Iterator>) {
+ return (Iterator>)value;
+ }
+ if (value.getClass().isArray()) {
+ final ArrayHelper helper = arrayHelper(value);
+ return new Iterator() {
+ private int _count = helper.length(value), _idx;
+ @Override public boolean hasNext () { return _idx < _count; }
+ @Override public Object next () { return helper.get(value, _idx++); }
+ @Override public void remove () { throw new UnsupportedOperationException(); }
+ };
+ }
+ return null;
+ }
+
+ public Mustache.VariableFetcher createFetcher (Object ctx, String name) {
+ if (ctx instanceof Mustache.CustomContext) return CUSTOM_FETCHER;
+ if (ctx instanceof Map,?>) return MAP_FETCHER;
+
+ // if the name looks like a number, potentially use one of our 'indexing' fetchers
+ char c = name.charAt(0);
+ if (c >= '0' && c <= '9') {
+ if (ctx instanceof List>) return LIST_FETCHER;
+ if (ctx instanceof Iterator>) return ITER_FETCHER;
+ if (ctx.getClass().isArray()) return arrayHelper(ctx);
+ }
+
+ return null;
+ }
+
+ /** This should return a thread-safe map, either {@link Collections#synchronizedMap} called on
+ * a standard {@link Map} implementation or something like {@code ConcurrentHashMap}. */
+ public abstract Map createFetcherCache ();
+
+ protected static ArrayHelper arrayHelper (Object ctx) {
+ if (ctx instanceof Object[]) return OBJECT_ARRAY_HELPER;
+ if (ctx instanceof boolean[]) return BOOLEAN_ARRAY_HELPER;
+ if (ctx instanceof byte[]) return BYTE_ARRAY_HELPER;
+ if (ctx instanceof char[]) return CHAR_ARRAY_HELPER;
+ if (ctx instanceof short[]) return SHORT_ARRAY_HELPER;
+ if (ctx instanceof int[]) return INT_ARRAY_HELPER;
+ if (ctx instanceof long[]) return LONG_ARRAY_HELPER;
+ if (ctx instanceof float[]) return FLOAT_ARRAY_HELPER;
+ if (ctx instanceof double[]) return DOUBLE_ARRAY_HELPER;
+ return null;
+ }
+
+ protected static final Mustache.VariableFetcher CUSTOM_FETCHER = new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ Mustache.CustomContext custom = (Mustache.CustomContext)ctx;
+ Object val = custom.get(name);
+ return val == null ? Template.NO_FETCHER_FOUND : val;
+ }
+ @Override public String toString () {
+ return "CUSTOM_FETCHER";
+ }
+ };
+
+ protected static final Mustache.VariableFetcher MAP_FETCHER = new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ Map,?> map = (Map,?>)ctx;
+ if (map.containsKey(name)) return map.get(name);
+ // special case to allow map entry set to be iterated over
+ if ("entrySet".equals(name)) return map.entrySet();
+ return Template.NO_FETCHER_FOUND;
+ }
+ @Override public String toString () {
+ return "MAP_FETCHER";
+ }
+ };
+
+ protected static final Mustache.VariableFetcher LIST_FETCHER = new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ try {
+ return ((List>)ctx).get(Integer.parseInt(name));
+ } catch (NumberFormatException nfe) {
+ return Template.NO_FETCHER_FOUND;
+ } catch (IndexOutOfBoundsException e) {
+ return Template.NO_FETCHER_FOUND;
+ }
+ }
+ @Override public String toString () {
+ return "LIST_FETCHER";
+ }
+ };
+
+ protected static final Mustache.VariableFetcher ITER_FETCHER = new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ try {
+ Iterator> iter = (Iterator>)ctx;
+ for (int ii = 0, ll = Integer.parseInt(name); ii < ll; ii++) iter.next();
+ return iter.next();
+ } catch (NumberFormatException nfe) {
+ return Template.NO_FETCHER_FOUND;
+ } catch (NoSuchElementException e) {
+ return Template.NO_FETCHER_FOUND;
+ }
+ }
+ @Override public String toString () {
+ return "ITER_FETCHER";
+ }
+ };
+
+ protected static abstract class ArrayHelper implements Mustache.VariableFetcher {
+ public Object get (Object ctx, String name) throws Exception {
+ try {
+ return get(ctx, Integer.parseInt(name));
+ } catch (NumberFormatException nfe) {
+ return Template.NO_FETCHER_FOUND;
+ } catch (ArrayIndexOutOfBoundsException e) {
+ return Template.NO_FETCHER_FOUND;
+ }
+ }
+ public abstract int length (Object ctx);
+ protected abstract Object get (Object ctx, int index);
+ }
+
+ protected static final ArrayHelper OBJECT_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((Object[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((Object[])ctx).length; }
+ };
+ protected static final ArrayHelper BOOLEAN_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((boolean[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((boolean[])ctx).length; }
+ };
+ protected static final ArrayHelper BYTE_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((byte[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((byte[])ctx).length; }
+ };
+ protected static final ArrayHelper CHAR_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((char[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((char[])ctx).length; }
+ };
+ protected static final ArrayHelper SHORT_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((short[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((short[])ctx).length; }
+ };
+ protected static final ArrayHelper INT_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((int[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((int[])ctx).length; }
+ };
+ protected static final ArrayHelper LONG_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((long[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((long[])ctx).length; }
+ };
+ protected static final ArrayHelper FLOAT_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((float[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((float[])ctx).length; }
+ };
+ protected static final ArrayHelper DOUBLE_ARRAY_HELPER = new ArrayHelper() {
+ @Override protected Object get (Object ctx, int index) { return ((double[])ctx)[index]; }
+ @Override public int length (Object ctx) { return ((double[])ctx).length; }
+ };
+}
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/DefaultCollector.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/DefaultCollector.java
new file mode 100644
index 00000000..2a638736
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/DefaultCollector.java
@@ -0,0 +1,149 @@
+// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne).
+// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695).
+// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package
+// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text.
+//
+//
+// JMustache - A Java implementation of the Mustache templating language
+// http://github.com/samskivert/jmustache/blob/master/LICENSE
+
+package com.launchdarkly.sdk.server.ai.internal.mustache;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * The default collector used by JMustache.
+ */
+public class DefaultCollector extends BasicCollector
+{
+ @Override
+ public Mustache.VariableFetcher createFetcher (Object ctx, String name) {
+ Mustache.VariableFetcher fetcher = super.createFetcher(ctx, name);
+ if (fetcher != null) return fetcher;
+
+ // first check for a getter which provides the value
+ Class> cclass = ctx.getClass();
+ final Method m = getMethod(cclass, name);
+ if (m != null) {
+ return new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ return m.invoke(ctx);
+ }
+ };
+ }
+
+ // next check for a getter which provides the value
+ final Field f = getField(cclass, name);
+ if (f != null) {
+ return new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ return f.get(ctx);
+ }
+ };
+ }
+
+ // finally check for a default interface method which provides the value (this is left to
+ // last because it's much more expensive and hopefully something already matched above)
+ final Method im = getIfaceMethod(cclass, name);
+ if (im != null) {
+ return new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ return im.invoke(ctx);
+ }
+ };
+ }
+
+ return null;
+ }
+
+ @Override
+ public Map createFetcherCache () {
+ return new ConcurrentHashMap();
+ }
+
+ protected Method getMethod (Class> clazz, String name) {
+ // first check up the superclass chain
+ for (Class> cc = clazz; cc != null && cc != Object.class; cc = cc.getSuperclass()) {
+ Method m = getMethodOn(cc, name);
+ if (m != null) return m;
+ }
+ return null;
+ }
+
+ protected Method getIfaceMethod (Class> clazz, String name) {
+ // enumerate the transitive closure of all interfaces implemented by clazz
+ Set> ifaces = new LinkedHashSet>();
+ for (Class> cc = clazz; cc != null && cc != Object.class; cc = cc.getSuperclass()) {
+ addIfaces(ifaces, cc, false);
+ }
+ // now search those in the order that we found them
+ for (Class> iface : ifaces) {
+ Method m = getMethodOn(iface, name);
+ if (m != null) return m;
+ }
+ return null;
+ }
+
+ private void addIfaces (Set> ifaces, Class> clazz, boolean isIface) {
+ if (isIface) ifaces.add(clazz);
+ for (Class> iface : clazz.getInterfaces()) addIfaces(ifaces, iface, true);
+ }
+
+ protected Method getMethodOn (Class> clazz, String name) {
+ Method m;
+ try {
+ m = clazz.getDeclaredMethod(name);
+ if (!m.getReturnType().equals(void.class)) return makeAccessible(m);
+ } catch (Exception e) {
+ // fall through
+ }
+
+ String upperName = Character.toUpperCase(name.charAt(0)) + name.substring(1);
+ try {
+ m = clazz.getDeclaredMethod("get" + upperName);
+ if (!m.getReturnType().equals(void.class)) return makeAccessible(m);
+ } catch (Exception e) {
+ // fall through
+ }
+
+ try {
+ m = clazz.getDeclaredMethod("is" + upperName);
+ if (m.getReturnType().equals(boolean.class) ||
+ m.getReturnType().equals(Boolean.class)) return makeAccessible(m);
+ } catch (Exception e) {
+ // fall through
+ }
+
+ return null;
+ }
+
+ private Method makeAccessible (Method m) {
+ if (!m.isAccessible()) m.setAccessible(true);
+ return m;
+ }
+
+ protected Field getField (Class> clazz, String name) {
+ Field f;
+ try {
+ f = clazz.getDeclaredField(name);
+ if (!f.isAccessible()) {
+ f.setAccessible(true);
+ }
+ return f;
+ } catch (Exception e) {
+ // fall through
+ }
+
+ Class> sclass = clazz.getSuperclass();
+ if (sclass != Object.class && sclass != null) {
+ return getField(clazz.getSuperclass(), name);
+ }
+ return null;
+ }
+}
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Escapers.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Escapers.java
new file mode 100644
index 00000000..914b2712
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Escapers.java
@@ -0,0 +1,47 @@
+// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne).
+// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695).
+// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package
+// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text.
+//
+//
+// JMustache - A Java implementation of the Mustache templating language
+// http://github.com/samskivert/jmustache/blob/master/LICENSE
+
+package com.launchdarkly.sdk.server.ai.internal.mustache;
+
+/**
+ * Defines some standard {@link Mustache.Escaper}s.
+ */
+public class Escapers
+{
+ /** Escapes HTML entities. */
+ public static final Mustache.Escaper HTML = simple(new String[][] {
+ { "&", "&" },
+ { "'", "'" },
+ { "\"", """ },
+ { "<", "<" },
+ { ">", ">" },
+ { "`", "`" },
+ { "=", "=" }
+ });
+
+ /** An escaper that does no escaping. */
+ public static final Mustache.Escaper NONE = new Mustache.Escaper() {
+ @Override public String escape (String text) {
+ return text;
+ }
+ };
+
+ /** Returns an escaper that replaces a list of text sequences with canned replacements.
+ * @param repls a list of {@code (text, replacement)} pairs. */
+ public static Mustache.Escaper simple (final String[]... repls) {
+ return new Mustache.Escaper() {
+ @Override public String escape (String text) {
+ for (String[] escape : repls) {
+ text = text.replace(escape[0], escape[1]);
+ }
+ return text;
+ }
+ };
+ }
+}
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Mustache.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Mustache.java
new file mode 100644
index 00000000..5e6ea604
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Mustache.java
@@ -0,0 +1,1048 @@
+// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne).
+// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695).
+// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package
+// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text.
+//
+//
+// JMustache - A Java implementation of the Mustache templating language
+// http://github.com/samskivert/jmustache/blob/master/LICENSE
+
+package com.launchdarkly.sdk.server.ai.internal.mustache;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.io.Writer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Provides Mustache templating services.
+ *
+ * Basic usage:
+ *
{@code
+ * String source = "Hello {{arg}}!";
+ * Template tmpl = Mustache.compiler().compile(source);
+ * Map context = new HashMap();
+ * context.put("arg", "world");
+ * tmpl.execute(context); // returns "Hello world!"
+ * }
+ */
+public class Mustache {
+
+ /** Compiles templates into executable form. See {@link Mustache}. */
+ public static class Compiler {
+
+ /** Whether or not standards mode is enabled. */
+ public final boolean standardsMode;
+
+ /** Whether or not to throw an exception when a section resolves to a missing value. If
+ * false, the section is simply omitted (or included in the case of inverse sections). If
+ * true, a {@code MustacheException} is thrown. */
+ public final boolean strictSections;
+
+ /** A value to use when a variable resolves to null. If this value is null (which is the
+ * default null value), an exception will be thrown. If {@link #missingIsNull} is also
+ * true, this value will be used when a variable cannot be resolved.
+ *
+ * If the nullValue contains a substring {@code {{name}}}, then this substring will be
+ * replaced by name of the variable. For example, if nullValue is {@code ?{{name}}?} and
+ * the missing variable is {@code foo}, then string {@code ?foo?} will be used.
*/
+ public final String nullValue;
+
+ /** If this value is true, missing variables will be treated like variables that return
+ * null. {@link #nullValue} will be used in their place, assuming {@link #nullValue} is
+ * configured to a non-null value. */
+ public final boolean missingIsNull;
+
+ /** If this value is true, empty string will be treated as a false value, as in JavaScript
+ * mustache implementation. Default is false. */
+ public final boolean emptyStringIsFalse;
+
+ /** If this value is true, zero will be treated as a false value, as in JavaScript
+ * mustache implementation. Default is false. */
+ public final boolean zeroIsFalse;
+
+ /** Handles converting objects to strings when rendering a template. The default formatter
+ * uses {@link String#valueOf}. */
+ public final Formatter formatter;
+
+ /** Handles escaping characters in substituted text. */
+ public final Escaper escaper;
+
+ /** The template loader in use during this compilation. */
+ public final TemplateLoader loader;
+
+ /** The collector used by templates compiled with this compiler. */
+ public final Collector collector;
+
+ /** The delimiters used by default in templates compiled with this compiler. */
+ public final Delims delims;
+
+ /** Compiles the supplied template into a repeatedly executable intermediate form. */
+ public Template compile (String template) {
+ return compile(new StringReader(template));
+ }
+
+ /** Compiles the supplied template into a repeatedly executable intermediate form. */
+ public Template compile (Reader source) {
+ return Mustache.compile(source, this);
+ }
+
+ /** Returns a compiler that either does or does not escape HTML by default. Note: this
+ * overrides any escaper set via {@link #withEscaper}. */
+ public Compiler escapeHTML (boolean escapeHTML) {
+ return withEscaper(escapeHTML ? Escapers.HTML : Escapers.NONE);
+ }
+
+ /** Returns a compiler that either does or does not use standards mode. Standards mode
+ * disables the non-standard JMustache extensions like looking up missing names in a parent
+ * context. */
+ public Compiler standardsMode (boolean standardsMode) {
+ return new Compiler(standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse,
+ this.formatter, this.escaper, this.loader, this.collector,
+ this.delims);
+ }
+
+ /** Returns a compiler that throws an exception when a section references a missing value
+ * ({@code true}) or treats a missing value as {@code false} ({@code false}, the default).
+ */
+ public Compiler strictSections (boolean strictSections) {
+ return new Compiler(this.standardsMode, strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse,
+ this.formatter, this.escaper, this.loader, this.collector,
+ this.delims);
+ }
+
+ /** Returns a compiler that will use the given value for any variable that is missing, or
+ * otherwise resolves to null. This is like {@link #nullValue} except that it returns the
+ * supplied default for missing keys and existing keys that return null values. */
+ public Compiler defaultValue (String defaultValue) {
+ return new Compiler(this.standardsMode, this.strictSections, defaultValue, true,
+ this.emptyStringIsFalse, this.zeroIsFalse, this.formatter,
+ this.escaper, this.loader, this.collector, this.delims);
+ }
+
+ /** Returns a compiler that will use the given value for any variable that resolves to
+ * null, but will still raise an exception for variables for which an accessor cannot be
+ * found. This is like {@link #defaultValue} except that it differentiates between missing
+ * accessors, and accessors that exist but return null.
+ *
+ * In the case of a Java object being used as a context, if no field or method can be
+ * found for a variable, an exception will be raised.
+ * In the case of a {@link Map} being used as a context, if the map does not contain
+ * a mapping for a variable, an exception will be raised. If the map contains a mapping
+ * which maps to {@code null}, then {@code nullValue} is used.
+ * */
+ public Compiler nullValue (String nullValue) {
+ return new Compiler(this.standardsMode, this.strictSections, nullValue, false,
+ this.emptyStringIsFalse, this.zeroIsFalse, this.formatter,
+ this.escaper, this.loader, this.collector, this.delims);
+ }
+
+ /** Returns a compiler that will treat empty string as a false value if parameter is true. */
+ public Compiler emptyStringIsFalse (boolean emptyStringIsFalse) {
+ return new Compiler(this.standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, emptyStringIsFalse, this.zeroIsFalse,
+ this.formatter, this.escaper, this.loader, this.collector,
+ this.delims);
+ }
+
+ /** Returns a compiler that will treat zero as a false value if parameter is true. */
+ public Compiler zeroIsFalse (boolean zeroIsFalse) {
+ return new Compiler(this.standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, zeroIsFalse,
+ this.formatter, this.escaper, this.loader, this.collector,
+ this.delims);
+ }
+
+ /** Configures the {@link Formatter} used to turn objects into strings. */
+ public Compiler withFormatter (Formatter formatter) {
+ return new Compiler(this.standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse,
+ formatter, this.escaper, this.loader, this.collector, this.delims);
+ }
+
+ /** Configures the {@link Escaper} used to escape substituted text. */
+ public Compiler withEscaper (Escaper escaper) {
+ return new Compiler(this.standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse,
+ this.formatter, escaper, this.loader, this.collector, this.delims);
+ }
+
+ /** Returns a compiler configured to use the supplied template loader to handle partials. */
+ public Compiler withLoader (TemplateLoader loader) {
+ return new Compiler(this.standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse,
+ this.formatter, this.escaper, loader, this.collector, this.delims);
+ }
+
+ /** Returns a compiler configured to use the supplied collector. */
+ public Compiler withCollector (Collector collector) {
+ return new Compiler(this.standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse,
+ this.formatter, this.escaper, this.loader, collector, this.delims);
+ }
+
+ /** Returns a compiler configured to use the supplied delims as default delimiters.
+ * @param delims a string of the form {@code AB CD} or {@code A D} where A and B are
+ * opening delims and C and D are closing delims. */
+ public Compiler withDelims (String delims) {
+ return new Compiler(this.standardsMode, this.strictSections, this.nullValue,
+ this.missingIsNull, this.emptyStringIsFalse, this.zeroIsFalse,
+ this.formatter, this.escaper, this.loader, this.collector,
+ new Delims().updateDelims(delims));
+ }
+
+ /** Returns the value to use in the template for the null-valued property {@code name}. See
+ * {@link #nullValue} for more details. */
+ public String computeNullValue (String name) {
+ return (nullValue == null) ? null : nullValue.replace("{{name}}", name);
+ }
+
+ /** Returns true if the supplied value is "falsey". If {@link #emptyStringIsFalse} is true,
+ * then empty strings are considered falsey. If {@link #zeroIsFalse} is true, then zero
+ * values are considered falsey. */
+ public boolean isFalsey (Object value) {
+ return ((emptyStringIsFalse && "".equals(formatter.format(value))) ||
+ (zeroIsFalse && (value instanceof Number) && ((Number)value).longValue() == 0));
+ }
+
+ /** Loads and compiles the template {@code name} using this compiler's configured template
+ * loader. Note that this does no caching: the caller should cache the loaded template if
+ * they expect to use it multiple times.
+ * @return the compiled template.
+ * @throw MustacheException if the template could not be loaded (due to I/O exception) or
+ * compiled (due to syntax error, etc.).
+ */
+ public Template loadTemplate (String name) throws MustacheException {
+ Reader tin = null;
+ try {
+ tin = loader.getTemplate(name);
+ return compile(tin);
+ } catch (Exception e) {
+ if (e instanceof RuntimeException) {
+ throw (RuntimeException)e;
+ } else {
+ throw new MustacheException("Unable to load template: " + name, e);
+ }
+ } finally {
+ if (tin != null) try {
+ tin.close();
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ }
+ }
+
+ protected Compiler (boolean standardsMode, boolean strictSections, String nullValue,
+ boolean missingIsNull, boolean emptyStringIsFalse, boolean zeroIsFalse,
+ Formatter formatter, Escaper escaper, TemplateLoader loader,
+ Collector collector, Delims delims) {
+ this.standardsMode = standardsMode;
+ this.strictSections = strictSections;
+ this.nullValue = nullValue;
+ this.missingIsNull = missingIsNull;
+ this.emptyStringIsFalse = emptyStringIsFalse;
+ this.zeroIsFalse = zeroIsFalse;
+ this.formatter = formatter;
+ this.escaper = escaper;
+ this.loader = loader;
+ this.collector = collector;
+ this.delims = delims;
+ }
+ }
+
+ /** Handles converting objects to strings when rendering templates. */
+ public interface Formatter {
+
+ /** Converts {@code value} to a string for inclusion in a template. */
+ String format (Object value);
+ }
+
+ /** Handles lambdas. */
+ public interface Lambda {
+
+ /** Executes this lambda on the supplied template fragment. The lambda should write its
+ * results to {@code out}.
+ *
+ * @param frag the fragment of the template that was passed to the lambda.
+ * @param out the writer to which the lambda should write its output.
+ */
+ void execute (Template.Fragment frag, Writer out) throws IOException;
+ }
+
+ /** Handles lambdas that are also invoked for inverse sections.. */
+ public interface InvertibleLambda extends Lambda {
+
+ /** Executes this lambda on the supplied template fragment, when the lambda is used in an
+ * inverse section. The lambda should write its results to {@code out}.
+ *
+ * @param frag the fragment of the template that was passed to the lambda.
+ * @param out the writer to which the lambda should write its output.
+ */
+ void executeInverse (Template.Fragment frag, Writer out) throws IOException;
+ }
+
+ /** Reads variables from context objects. */
+ public interface VariableFetcher {
+
+ /** Reads the so-named variable from the supplied context object. */
+ Object get (Object ctx, String name) throws Exception;
+ }
+
+ /** Handles escaping characters in substituted text. */
+ public interface Escaper {
+
+ /** Returns {@code raw} with the appropriate characters replaced with escape sequences. */
+ String escape (String raw);
+ }
+
+ /** Handles loading partial templates. */
+ public interface TemplateLoader {
+
+ /** Returns a reader for the template with the supplied name.
+ * Reader will be closed by callee.
+ * @throws Exception if the template could not be loaded for any reason. */
+ Reader getTemplate (String name) throws Exception;
+ }
+
+ /** Handles interpreting objects as collections. */
+ public interface Collector {
+
+ /** Returns an iterator that can iterate over the supplied value, or null if the value is
+ * not a collection. */
+ Iterator> toIterator (final Object value);
+
+ /** Creates a fetcher for a so-named variable in the supplied context object, which will
+ * never be null. The fetcher will be cached and reused for future contexts for which
+ * {@code octx.getClass().equals(nctx.getClass()}. */
+ VariableFetcher createFetcher (Object ctx, String name);
+
+ /** Creates a map to be used to cache {@link VariableFetcher} instances. The GWT-compatible
+ * collector returns a HashMap here, but the reflection based fetcher (which only works on
+ * the JVM and Android, returns a concurrent hashmap. */
+ Map createFetcherCache ();
+ }
+
+ /**
+ * Provides a means to implement custom logic for variable lookup. If a context object
+ * implements this interface, its {@code get} method will be used to look up variables instead
+ * of the usual methods.
+ *
+ * This is simpler than having a context implement {@link Map} which would require that it also
+ * support the {@link Map#entrySet} method for iteration. A {@code CustomContext} object cannot
+ * be used for a list section.
+ */
+ public interface CustomContext {
+
+ /** Fetches the value of a variable named {@code name}. */
+ Object get (String name) throws Exception;
+ }
+
+ /** Used to visit the tags in a template without executing it. */
+ public interface Visitor {
+
+ /** Visits a text segment. These are blocks of text that are normally just reproduced as
+ * is when executing a template.
+ * @param text the block of text. May contain newlines.
+ */
+ void visitText (String text);
+
+ /** Visits a variable tag.
+ * @param name the name of the variable.
+ */
+ void visitVariable (String name);
+
+ /** Visits an include (partial) tag.
+ * @param name the name of the partial template specified by the tag.
+ * @return true if the template should be resolved and visited, false to skip it.
+ */
+ boolean visitInclude (String name);
+
+ /** Visits a section tag.
+ * @param name the name of the section.
+ * @return true if the contents of the section should be visited, false to skip.
+ */
+ boolean visitSection (String name);
+
+ /** Visits an inverted section tag.
+ * @param name the name of the inverted section.
+ * @return true if the contents of the section should be visited, false to skip.
+ */
+ boolean visitInvertedSection (String name);
+ }
+
+ /**
+ * Returns a compiler that escapes HTML by default and does not use standards mode.
+ */
+ public static Compiler compiler () {
+ return new Compiler(/*standardsMode=*/false, /*strictSections=*/false, /*nullValue=*/null,
+ /*missingIsNull=*/false, /*emptyStringIsFalse=*/false,
+ /*zeroIsFalse=*/false, DEFAULT_FORMATTER, Escapers.HTML, FAILING_LOADER,
+ new DefaultCollector(), new Delims());
+ }
+
+ /**
+ * Compiles the supplied template into a repeatedly executable intermediate form.
+ */
+ protected static Template compile (Reader source, Compiler compiler) {
+ Accumulator accum = new Parser(compiler).parse(source);
+ return new Template(trim(accum.finish(), true), compiler);
+ }
+
+ private Mustache () {} // no instantiateski
+
+ protected static Template.Segment[] trim (Template.Segment[] segs, boolean top) {
+ // now that we have all of our segments, we make a pass through them to trim whitespace
+ // from section tags which stand alone on their lines
+ for (int ii = 0, ll = segs.length; ii < ll; ii++) {
+ Template.Segment seg = segs[ii];
+ Template.Segment pseg = (ii > 0 ) ? segs[ii-1] : null;
+ Template.Segment nseg = (ii < ll-1) ? segs[ii+1] : null;
+ StringSegment prev = (pseg instanceof StringSegment) ? (StringSegment)pseg : null;
+ StringSegment next = (nseg instanceof StringSegment) ? (StringSegment)nseg : null;
+ // if we're at the top-level there are virtual "blank lines" before & after segs
+ boolean prevBlank = ((pseg == null && top) || (prev != null && prev.trailsBlank()));
+ boolean nextBlank = ((nseg == null && top) || (next != null && next.leadsBlank()));
+ // potentially trim around the open and close tags of a block segment
+ if (seg instanceof BlockSegment) {
+ BlockSegment block = (BlockSegment)seg;
+ if (prevBlank && block.firstLeadsBlank()) {
+ if (pseg != null) segs[ii-1] = prev.trimTrailBlank();
+ block.trimFirstBlank();
+ }
+ if (nextBlank && block.lastTrailsBlank()) {
+ block.trimLastBlank();
+ if (nseg != null) segs[ii+1] = next.trimLeadBlank();
+ }
+ }
+ // potentially trim around non-printing (comments/delims) segments
+ else if (seg instanceof FauxSegment) {
+ if (prevBlank && nextBlank) {
+ if (pseg != null) segs[ii-1] = prev.trimTrailBlank();
+ if (nseg != null) segs[ii+1] = next.trimLeadBlank();
+ }
+ }
+ }
+ return segs;
+ }
+
+ protected static void restoreStartTag (StringBuilder text, Delims starts) {
+ text.insert(0, starts.start1);
+ if (starts.start2 != NO_CHAR) {
+ text.insert(1, starts.start2);
+ }
+ }
+
+ // TODO: this method was never called, what was my intention here?
+ protected static boolean allowsWhitespace (char typeChar) {
+ return (typeChar == '=' /* change delimiters */) || (typeChar == '!' /* comment */);
+ }
+
+ protected static final int TEXT = 0;
+ protected static final int MATCHING_START = 1;
+ protected static final int MATCHING_END = 2;
+ protected static final int TAG = 3;
+
+ // a hand-rolled parser; whee!
+ protected static class Parser {
+ final Delims delims;
+ final StringBuilder text = new StringBuilder();
+
+ Reader source;
+ Accumulator accum;
+
+ int state = TEXT;
+ int line = 1, column = 0;
+ int tagStartColumn = -1;
+
+ public Parser (Compiler compiler) {
+ this.accum = new Accumulator(compiler, true);
+ this.delims = compiler.delims.copy();
+ }
+
+ public Accumulator parse (Reader source) {
+ this.source = source;
+
+ int v;
+ while ((v = nextChar()) != -1) {
+ char c = (char)v;
+ ++column; // our columns start at one, so increment before parse
+ parseChar(c);
+ // if we just parsed a newline, reset the column to zero and advance line
+ if (c == '\n') {
+ column = 0;
+ ++line;
+ }
+ }
+
+ // accumulate any trailing text
+ switch (state) {
+ case TAG:
+ restoreStartTag(text, delims);
+ break;
+ case MATCHING_END:
+ restoreStartTag(text, delims);
+ text.append(delims.end1);
+ break;
+ case MATCHING_START:
+ text.append(delims.start1);
+ break;
+ case TEXT: // do nothing
+ break;
+ }
+ accum.addTextSegment(text);
+
+ return accum;
+ }
+
+ protected void parseChar (char c) {
+ switch (state) {
+ case TEXT:
+ if (c == delims.start1) {
+ state = MATCHING_START;
+ tagStartColumn = column;
+ if (delims.start2 == NO_CHAR) {
+ parseChar(NO_CHAR);
+ }
+ } else {
+ text.append(c);
+ }
+ break;
+
+ case MATCHING_START:
+ if (c == delims.start2) {
+ accum.addTextSegment(text);
+ state = TAG;
+ } else {
+ text.append(delims.start1);
+ state = TEXT;
+ parseChar(c);
+ }
+ break;
+
+ case TAG:
+ if (c == delims.end1) {
+ state = MATCHING_END;
+ if (delims.end2 == NO_CHAR) {
+ parseChar(NO_CHAR);
+ }
+
+ } else if (c == delims.start1 && text.length() > 0 && text.charAt(0) != '!') {
+ // if we've already matched some tag characters and we see a new start tag
+ // character (e.g. "{{foo {" but not "{{{"), treat the already matched text as
+ // plain text and start matching a new tag from this point, unless we're in
+ // a comment tag.
+ restoreStartTag(text, delims);
+ accum.addTextSegment(text);
+ tagStartColumn = column;
+ if (delims.start2 == NO_CHAR) {
+ accum.addTextSegment(text);
+ state = TAG;
+ } else {
+ state = MATCHING_START;
+ }
+
+ } else {
+ text.append(c);
+ }
+ break;
+
+ case MATCHING_END:
+ if (c == delims.end2) {
+ if (text.charAt(0) == '=') {
+ delims.updateDelims(text.substring(1, text.length()-1));
+ text.setLength(0);
+ accum.addFauxSegment(); // for newline trimming
+ } else {
+ // if the delimiters are {{ and }}, and the tag starts with {{{ then
+ // require that it end with }}} and disable escaping
+ if (delims.isStaches() && text.charAt(0) == delims.start1) {
+ // we've only parsed }} at this point, so we have to slurp in another
+ // character from the input stream and check it
+ int end3 = nextChar();
+ if (end3 != '}') {
+ String got = (end3 == -1) ? "" : String.valueOf((char)end3);
+ throw new MustacheParseException(
+ "Invalid triple-mustache tag: {{" + text + "}}" + got, line);
+ }
+ // convert it into (equivalent) {{&text}} which addTagSegment handles
+ text.replace(0, 1, "&");
+ }
+ // process the tag between the mustaches
+ accum = accum.addTagSegment(text, line);
+ }
+ state = TEXT;
+
+ } else {
+ text.append(delims.end1);
+ state = TAG;
+ parseChar(c);
+ }
+ break;
+ }
+ }
+
+ protected int nextChar () {
+ try {
+ return source.read();
+ } catch (IOException ioe) {
+ throw new MustacheException(ioe);
+ }
+ }
+ }
+
+ protected static class Delims {
+ public char start1 = '{', end1 = '}';
+ public char start2 = '{', end2 = '}';
+
+ public boolean isStaches () {
+ return start1 == '{' && start2 == '{' && end1 == '}' && end2 == '}';
+ }
+
+ public Delims updateDelims (String dtext) {
+ String[] delims = dtext.split(" ");
+ if (delims.length != 2) throw new MustacheException(errmsg(dtext));
+
+ switch (delims[0].length()) {
+ case 1:
+ start1 = delims[0].charAt(0);
+ start2 = NO_CHAR;
+ break;
+ case 2:
+ start1 = delims[0].charAt(0);
+ start2 = delims[0].charAt(1);
+ break;
+ default:
+ throw new MustacheException(errmsg(dtext));
+ }
+
+ switch (delims[1].length()) {
+ case 1:
+ end1 = delims[1].charAt(0);
+ end2 = NO_CHAR;
+ break;
+ case 2:
+ end1 = delims[1].charAt(0);
+ end2 = delims[1].charAt(1);
+ break;
+ default:
+ throw new MustacheException(errmsg(dtext));
+ }
+ return this;
+ }
+
+ public void addTag (char prefix, String name, StringBuilder into) {
+ into.append(start1);
+ into.append(start2);
+ if (prefix != ' ') into.append(prefix);
+ into.append(name);
+ into.append(end1);
+ into.append(end2);
+ }
+
+ Delims copy () {
+ Delims d = new Delims();
+ d.start1 = start1;
+ d.start2 = start2;
+ d.end1 = end1;
+ d.end2 = end2;
+ return d;
+ }
+
+ private static String errmsg (String dtext) {
+ return "Invalid delimiter configuration '" + dtext + "'. Must be of the " +
+ "form {{=1 2=}} or {{=12 34=}} where 1, 2, 3 and 4 are delimiter chars.";
+ }
+ }
+
+ protected static class Accumulator {
+ public Accumulator (Compiler compiler, boolean topLevel) {
+ _comp = compiler;
+ _topLevel = topLevel;
+ }
+
+ public void addTextSegment (StringBuilder text) {
+ if (text.length() > 0) {
+ _segs.add(new StringSegment(text.toString(), _segs.isEmpty() && _topLevel));
+ text.setLength(0);
+ }
+ }
+
+ public Accumulator addTagSegment (final StringBuilder accum, final int tagLine) {
+ final Accumulator outer = this;
+ String tag = accum.toString().trim();
+ final String tag1 = tag.substring(1).trim();
+ accum.setLength(0);
+
+ switch (tag.charAt(0)) {
+ case '#':
+ requireNoNewlines(tag, tagLine);
+ return new Accumulator(_comp, false) {
+ @Override public Template.Segment[] finish () {
+ throw new MustacheParseException(
+ "Section missing close tag '" + tag1 + "'", tagLine);
+ }
+ @Override protected Accumulator addCloseSectionSegment (String itag, int line) {
+ requireSameName(tag1, itag, line);
+ outer._segs.add(new SectionSegment(_comp, itag, super.finish(), tagLine));
+ return outer;
+ }
+ };
+
+ case '>':
+ _segs.add(new IncludedTemplateSegment(_comp, tag1));
+ return this;
+
+ case '^':
+ requireNoNewlines(tag, tagLine);
+ return new Accumulator(_comp, false) {
+ @Override public Template.Segment[] finish () {
+ throw new MustacheParseException(
+ "Inverted section missing close tag '" + tag1 + "'", tagLine);
+ }
+ @Override protected Accumulator addCloseSectionSegment (String itag, int line) {
+ requireSameName(tag1, itag, line);
+ outer._segs.add(new InvertedSegment(_comp, itag, super.finish(), tagLine));
+ return outer;
+ }
+ };
+
+ case '/':
+ requireNoNewlines(tag, tagLine);
+ return addCloseSectionSegment(tag1, tagLine);
+
+ case '!':
+ // comment!, ignore
+ _segs.add(new FauxSegment()); // for whitespace trimming
+ return this;
+
+ case '&':
+ requireNoNewlines(tag, tagLine);
+ _segs.add(new VariableSegment(tag1, tagLine, _comp.formatter, Escapers.NONE));
+ return this;
+
+ default:
+ requireNoNewlines(tag, tagLine);
+ _segs.add(new VariableSegment(tag, tagLine, _comp.formatter, _comp.escaper));
+ return this;
+ }
+ }
+
+ public void addFauxSegment () {
+ _segs.add(new FauxSegment());
+ }
+
+ public Template.Segment[] finish () {
+ return _segs.toArray(new Template.Segment[_segs.size()]);
+ }
+
+ protected Accumulator addCloseSectionSegment (String tag, int line) {
+ throw new MustacheParseException(
+ "Section close tag with no open tag '" + tag + "'", line);
+ }
+
+ protected static void requireNoNewlines (String tag, int line) {
+ if (tag.indexOf('\n') != -1 || tag.indexOf('\r') != -1) {
+ throw new MustacheParseException(
+ "Invalid tag name: contains newline '" + tag + "'", line);
+ }
+ }
+
+ protected static void requireSameName (String name1, String name2, int line) {
+ if (!name1.equals(name2)) {
+ throw new MustacheParseException("Section close tag with mismatched open tag '" +
+ name2 + "' != '" + name1 + "'", line);
+ }
+ }
+
+ protected final Compiler _comp;
+ protected final boolean _topLevel;
+ protected final List _segs = new ArrayList();
+ }
+
+ /** A simple segment that reproduces a string. */
+ protected static class StringSegment extends Template.Segment {
+ public StringSegment (String text, boolean first) {
+ this(text, blankPos(text, true, first), blankPos(text, false, first));
+ }
+
+ public StringSegment (String text, int leadBlank, int trailBlank) {
+ assert leadBlank >= -1;
+ assert trailBlank >= -1;
+ _text = text;
+ _leadBlank = leadBlank;
+ _trailBlank = trailBlank;
+ }
+
+ public boolean leadsBlank () { return _leadBlank != -1; }
+ public boolean trailsBlank () { return _trailBlank != -1; }
+
+ public StringSegment trimLeadBlank () {
+ if (_leadBlank == -1) return this;
+ int lpos = _leadBlank+1, newTrail = _trailBlank == -1 ? -1 : _trailBlank-lpos;
+ return new StringSegment(_text.substring(lpos), -1, newTrail);
+ }
+ public StringSegment trimTrailBlank () {
+ return _trailBlank == -1 ? this : new StringSegment(
+ _text.substring(0, _trailBlank), _leadBlank, -1);
+ }
+
+ @Override public void execute (Template tmpl, Template.Context ctx, Writer out) {
+ write(out, _text);
+ }
+ @Override public void decompile (Delims delims, StringBuilder into) {
+ into.append(_text);
+ }
+ @Override public void visit (Visitor visitor) {
+ visitor.visitText(_text);
+ }
+ @Override public String toString () {
+ return "Text(" + _text.replace("\r", "\\r").replace("\n", "\\n") + ")" +
+ _leadBlank + "/" + _trailBlank;
+ }
+
+ private static int blankPos (String text, boolean leading, boolean first) {
+ int len = text.length();
+ for (int ii = leading ? 0 : len-1, ll = leading ? len : -1, dd = leading ? 1 : -1;
+ ii != ll; ii += dd) {
+ char c = text.charAt(ii);
+ if (c == '\n') return leading ? ii : ii+1;
+ if (!Character.isWhitespace(c)) return -1;
+ }
+ // if this is the first string segment and we're looking for trailing whitespace, a
+ // totally blank segment (but which lacks a newline) is all trailing whitespace
+ return (leading || !first) ? -1 : 0;
+ }
+
+ protected final String _text;
+ protected final int _leadBlank, _trailBlank;
+ }
+
+ /** A segment that loads and executes a sub-template. */
+ protected static class IncludedTemplateSegment extends Template.Segment {
+ public IncludedTemplateSegment (Compiler compiler, String name) {
+ _comp = compiler;
+ _name = name;
+ }
+ @Override public void execute (Template tmpl, Template.Context ctx, Writer out) {
+ // we must take care to preserve our context rather than creating a new one, which
+ // would happen if we just called execute() with ctx.data
+ getTemplate().executeSegs(ctx, out);
+ }
+ @Override public void decompile (Delims delims, StringBuilder into) {
+ delims.addTag('>', _name, into);
+ }
+ @Override public void visit (Visitor visitor) {
+ if (visitor.visitInclude(_name)) {
+ getTemplate().visit(visitor);
+ }
+ }
+ protected Template getTemplate () {
+ // we compile our template lazily to avoid infinie recursion if a template includes
+ // itself (see issue #13)
+ if (_template == null) {
+ _template = _comp.loadTemplate(_name);
+ }
+ return _template;
+ }
+ protected final Compiler _comp;
+ protected final String _name;
+ private Template _template;
+ }
+
+ /** A helper class for named segments. */
+ protected static abstract class NamedSegment extends Template.Segment {
+ protected NamedSegment (String name, int line) {
+ _name = name;
+ _line = line;
+ }
+ protected final String _name;
+ protected final int _line;
+ }
+
+ /** A segment that substitutes the contents of a variable. */
+ protected static class VariableSegment extends NamedSegment {
+ public VariableSegment (String name, int line, Formatter formatter, Escaper escaper) {
+ super(name, line);
+ _formatter = formatter;
+ _escaper = escaper;
+ }
+ @Override public void execute (Template tmpl, Template.Context ctx, Writer out) {
+ Object value = tmpl.getValueOrDefault(ctx, _name, _line);
+ if (value == null) {
+ String msg = Template.isThisName(_name) ?
+ "Resolved '.' to null (which is disallowed), on line " + _line :
+ "No key, method or field with name '" + _name + "' on line " + _line;
+ throw new MustacheException.Context(msg, _name, _line);
+ }
+ write(out, _escaper.escape(_formatter.format(value)));
+ }
+ @Override public void decompile (Delims delims, StringBuilder into) {
+ delims.addTag(' ', _name, into);
+ }
+ @Override public void visit (Visitor visitor) {
+ visitor.visitVariable(_name);
+ }
+ @Override public String toString () {
+ return "Var(" + _name + ":" + _line + ")";
+ }
+ protected final Formatter _formatter;
+ protected final Escaper _escaper;
+ }
+
+ /** A helper class for block segments. */
+ protected static abstract class BlockSegment extends NamedSegment {
+ public boolean firstLeadsBlank () {
+ if (_segs.length == 0 || !(_segs[0] instanceof StringSegment)) return false;
+ return ((StringSegment)_segs[0]).leadsBlank();
+ }
+ public void trimFirstBlank () {
+ _segs[0] = ((StringSegment)_segs[0]).trimLeadBlank();
+ }
+
+ public boolean lastTrailsBlank () {
+ int lastIdx = _segs.length-1;
+ if (_segs.length == 0 || !(_segs[lastIdx] instanceof StringSegment)) return false;
+ return ((StringSegment)_segs[lastIdx]).trailsBlank();
+ }
+ public void trimLastBlank () {
+ int idx = _segs.length-1;
+ _segs[idx] = ((StringSegment)_segs[idx]).trimTrailBlank();
+ }
+
+ protected BlockSegment (String name, Template.Segment[] segs, int line) {
+ super(name, line);
+ _segs = trim(segs, false);
+ }
+ protected void executeSegs (Template tmpl, Template.Context ctx, Writer out) {
+ for (Template.Segment seg : _segs) {
+ seg.execute(tmpl, ctx, out);
+ }
+ }
+
+ protected final Template.Segment[] _segs;
+ }
+
+ /** A segment that represents a section. */
+ protected static class SectionSegment extends BlockSegment {
+ public SectionSegment (Compiler compiler, String name, Template.Segment[] segs, int line) {
+ super(name, segs, line);
+ _comp = compiler;
+ }
+ @Override public void execute (Template tmpl, Template.Context ctx, Writer out) {
+ Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null
+ Iterator> iter = _comp.collector.toIterator(value);
+ if (iter != null) {
+ int index = 0;
+ while (iter.hasNext()) {
+ Object elem = iter.next();
+ boolean onFirst = (index == 0), onLast = !iter.hasNext();
+ executeSegs(tmpl, ctx.nest(elem, ++index, onFirst, onLast), out);
+ }
+ } else if (value instanceof Boolean) {
+ if ((Boolean)value) {
+ executeSegs(tmpl, ctx, out);
+ }
+ } else if (value instanceof Lambda) {
+ try {
+ ((Lambda)value).execute(tmpl.createFragment(_segs, ctx), out);
+ } catch (IOException ioe) {
+ throw new MustacheException(ioe);
+ }
+ } else if (_comp.isFalsey(value)) {
+ // omit the section
+ } else {
+ executeSegs(tmpl, ctx.nest(value), out);
+ }
+ }
+ @Override public void decompile (Delims delims, StringBuilder into) {
+ delims.addTag('#', _name, into);
+ for (Template.Segment seg : _segs) seg.decompile(delims, into);
+ delims.addTag('/', _name, into);
+ }
+ @Override public void visit (Visitor visitor) {
+ if (visitor.visitSection(_name)) {
+ for (Template.Segment seg : _segs) {
+ seg.visit(visitor);
+ }
+ }
+ }
+ @Override public String toString () {
+ return "Section(" + _name + ":" + _line + "): " + Arrays.toString(_segs);
+ }
+ protected final Compiler _comp;
+ }
+
+ /** A segment that represents an inverted section. */
+ protected static class InvertedSegment extends BlockSegment {
+ public InvertedSegment (Compiler compiler, String name, Template.Segment[] segs, int line) {
+ super(name, segs, line);
+ _comp = compiler;
+ }
+ @Override public void execute (Template tmpl, Template.Context ctx, Writer out) {
+ Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null
+ Iterator> iter = _comp.collector.toIterator(value);
+ if (iter != null) {
+ if (!iter.hasNext()) {
+ executeSegs(tmpl, ctx, out);
+ }
+ } else if (value instanceof Boolean) {
+ if (!(Boolean)value) {
+ executeSegs(tmpl, ctx, out);
+ }
+ } else if (value instanceof InvertibleLambda) {
+ try {
+ ((InvertibleLambda)value).executeInverse(tmpl.createFragment(_segs, ctx), out);
+ } catch (IOException ioe) {
+ throw new MustacheException(ioe);
+ }
+ } else if (_comp.isFalsey(value)) {
+ executeSegs(tmpl, ctx, out);
+ } // TODO: fail?
+ }
+ @Override public void decompile (Delims delims, StringBuilder into) {
+ delims.addTag('^', _name, into);
+ for (Template.Segment seg : _segs) seg.decompile(delims, into);
+ delims.addTag('/', _name, into);
+ }
+ @Override public void visit (Visitor visitor) {
+ if (visitor.visitInvertedSection(_name)) {
+ for (Template.Segment seg : _segs) {
+ seg.visit(visitor);
+ }
+ }
+ }
+ @Override public String toString () {
+ return "Inverted(" + _name + ":" + _line + "): " + Arrays.toString(_segs);
+ }
+ protected final Compiler _comp;
+ }
+
+ protected static class FauxSegment extends Template.Segment {
+ @Override public void execute (Template tmpl, Template.Context ctx, Writer out) {} // nada
+ @Override public void decompile (Delims delims, StringBuilder into) {} // nada
+ @Override public void visit (Visitor visit) {}
+ @Override public String toString () { return "Faux"; }
+ }
+
+ /** Used when we have only a single character delimiter. */
+ protected static final char NO_CHAR = Character.MIN_VALUE;
+
+ protected static final TemplateLoader FAILING_LOADER = new TemplateLoader() {
+ public Reader getTemplate (String name) {
+ throw new UnsupportedOperationException("Template loading not configured");
+ }
+ };
+
+ protected static final Formatter DEFAULT_FORMATTER = new Formatter() {
+ public String format (Object value) {
+ return String.valueOf(value);
+ }
+ };
+}
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheException.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheException.java
new file mode 100644
index 00000000..35e777f3
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheException.java
@@ -0,0 +1,50 @@
+// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne).
+// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695).
+// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package
+// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text.
+//
+//
+// JMustache - A Java implementation of the Mustache templating language
+// http://github.com/samskivert/jmustache/blob/master/LICENSE
+
+package com.launchdarkly.sdk.server.ai.internal.mustache;
+
+/**
+ * An exception thrown when an error occurs parsing or executing a Mustache template.
+ */
+public class MustacheException extends RuntimeException
+{
+ /** An exception thrown if we encounter a context error (e.g. a missing variable) while
+ * compiling or executing a template. */
+ public static class Context extends MustacheException {
+ /** The key that caused the problem. */
+ public final String key;
+
+ /** The line number of the template on which the problem occurred. */
+ public final int lineNo;
+
+ public Context (String message, String key, int lineNo) {
+ super(message);
+ this.key = key;
+ this.lineNo = lineNo;
+ }
+
+ public Context (String message, String key, int lineNo, Throwable cause) {
+ super(message, cause);
+ this.key = key;
+ this.lineNo = lineNo;
+ }
+ }
+
+ public MustacheException (String message) {
+ super(message);
+ }
+
+ public MustacheException (Throwable cause) {
+ super(cause);
+ }
+
+ public MustacheException (String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheParseException.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheParseException.java
new file mode 100644
index 00000000..8cd27428
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/MustacheParseException.java
@@ -0,0 +1,24 @@
+// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne).
+// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695).
+// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package
+// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text.
+//
+//
+// JMustache - A Java implementation of the Mustache templating language
+// http://github.com/samskivert/jmustache/blob/master/LICENSE
+
+package com.launchdarkly.sdk.server.ai.internal.mustache;
+
+/**
+ * An exception thrown if we encounter an error while parsing a template.
+ */
+public class MustacheParseException extends MustacheException
+{
+ public MustacheParseException (String message) {
+ super(message);
+ }
+
+ public MustacheParseException (String message, int lineNo) {
+ super(message + " @ line " + lineNo);
+ }
+}
diff --git a/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Template.java b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Template.java
new file mode 100644
index 00000000..25dc4a9c
--- /dev/null
+++ b/lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/internal/mustache/Template.java
@@ -0,0 +1,441 @@
+// Vendored from com.samskivert:jmustache:1.15 (BSD 3-Clause, Copyright (c) 2010 Michael Bayne).
+// Relocated to com.launchdarkly.sdk.server.ai.internal.mustache for supply-chain hardening (AIC-2695).
+// Upstream: https://github.com/samskivert/jmustache -- unmodified except for this banner and the package
+// declaration below. See THIRD-PARTY-NOTICES.txt for the full license text.
+//
+//
+// JMustache - A Java implementation of the Mustache templating language
+// http://github.com/samskivert/jmustache/blob/master/LICENSE
+
+package com.launchdarkly.sdk.server.ai.internal.mustache;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Map;
+
+/**
+ * Represents a compiled template. Templates are executed with a context to generate
+ * output. The context can be any tree of objects. Variables are resolved against the context.
+ * Given a name {@code foo}, the following mechanisms are supported for resolving its value
+ * (and are sought in this order):
+ *
+ *
+ * If the variable has the special name {@code this} the context object itself will be
+ * returned. This is useful when iterating over lists.
+ * If the object is a {@link Map}, {@link Map#get} will be called with the string {@code foo}
+ * as the key.
+ * A method named {@code foo} in the supplied object (with non-void return value).
+ * A method named {@code getFoo} in the supplied object (with non-void return value).
+ * A field named {@code foo} in the supplied object.
+ *
+ *
+ * The field type, method return type, or map value type should correspond to the desired
+ * behavior if the resolved name corresponds to a section. {@link Boolean} is used for showing or
+ * hiding sections without binding a sub-context. Arrays, {@link Iterator} and {@link Iterable}
+ * implementations are used for sections that repeat, with the context bound to the elements of the
+ * array, iterator or iterable. Lambdas are current unsupported, though they would be easy enough
+ * to add if desire exists. See the Mustache
+ * documentation for more details on section behavior.
+ */
+public class Template {
+
+ /**
+ * Encapsulates a fragment of a template that is passed to a lambda. The fragment is bound to
+ * the variable context that was in effect at the time the lambda was called.
+ */
+ public abstract class Fragment {
+
+ /** Executes this fragment; writes its result to {@code out}. */
+ public abstract void execute (Writer out);
+
+ /** Executes this fragment with the provided context; writes its result to {@code out}. The
+ * provided context will be nested in the fragment's bound context. */
+ public abstract void execute (Object context, Writer out);
+
+ /** Executes {@code tmpl} using this fragment's bound context. This allows a lambda to
+ * resolve its fragment to a dynamically loaded template and then run that template with
+ * the same context as the lamda, allowing a lambda to act as a 'late bound' included
+ * template, i.e. you can decide which template to include based on information in the
+ * context. */
+ public abstract void executeTemplate (Template tmpl, Writer out);
+
+ /** Executes this fragment and returns its result as a string. */
+ public String execute () {
+ StringWriter out = new StringWriter();
+ execute(out);
+ return out.toString();
+ }
+
+ /** Executes this fragment with the provided context; returns its result as a string. The
+ * provided context will be nested in the fragment's bound context. */
+ public String execute (Object context) {
+ StringWriter out = new StringWriter();
+ execute(context, out);
+ return out.toString();
+ }
+
+ /** Returns the context object in effect for this fragment. The actual type of the object
+ * depends on the structure of the data passed to the top-level template. You know where
+ * your lambdas are executed, so you know what type to which to cast the context in order
+ * to inspect it (be that a {@code Map} or a POJO or something else). */
+ public abstract Object context ();
+
+ /** Like {@link #context()} btu returns the {@code n}th parent context object. {@code 0}
+ * returns the same value as {@link #context()}, {@code 1} returns the parent context,
+ * {@code 2} returns the grandparent and so forth. Note that if you request a parent that
+ * does not exist an exception will be thrown. You should only use this method when you
+ * know your lambda is run consistently in a context with a particular lineage. */
+ public abstract Object context (int n);
+
+ /** Decompiles the template inside this lamdba and returns an approximation of
+ * the original template from which it was parsed. This is not the exact character for
+ * character representation because the original text is not preserved because that would
+ * incur a huge memory penalty for all users of the library when the vast majority of
+ * them do not call decompile.
+ *
+ * Limitations:
+ *
Whitespace inside tags is not preserved: i.e. {@code {{ foo.bar }}} becomes
+ * {@code {{foo.bar}}}.
+ * If the delimiters are changed by the template, those are not preserved.
+ * The delimiters configured on the {@link Compiler} are used for all decompilation.
+ *
+ *
+ * This feature is meant to enable use of lambdas for i18n such that you can recover
+ * the contents of a lambda (so long as they're simple) to use as the lookup key for a
+ * translation string. For example: {@code {{#i18n}}Hello {{user.name}}!{{/i18n}}} can be
+ * sent to an {@code i18n} lambda which can use {@code decompile} to recover the text
+ * {@code Hello {{user.name}}!} to be looked up in a translation dictionary. The
+ * translated fragment could then be compiled and cached and then executed in lieu of the
+ * original fragment using {@link Template.Fragment#context}.
+ */
+ public String decompile () {
+ return decompile(new StringBuilder()).toString();
+ }
+
+ /** Decompiles this fragment into {@code into}. See {@link #decompile()}.
+ * @return {@code into} for call chaining. */
+ public abstract StringBuilder decompile (StringBuilder into);
+ }
+
+ /** A sentinel object that can be returned by a {@link Mustache.Collector} to indicate that a
+ * variable does not exist in a particular context. */
+ public static final Object NO_FETCHER_FOUND = new String("");
+
+ /**
+ * Executes this template with the given context, returning the results as a string.
+ * @throws MustacheException if an error occurs while executing or writing the template.
+ */
+ public String execute (Object context) throws MustacheException {
+ StringWriter out = new StringWriter();
+ execute(context, out);
+ return out.toString();
+ }
+
+ /**
+ * Executes this template with the given context, writing the results to the supplied writer.
+ * @throws MustacheException if an error occurs while executing or writing the template.
+ */
+ public void execute (Object context, Writer out) throws MustacheException {
+ executeSegs(new Context(context, null, 0, false, false), out);
+ }
+
+ /**
+ * Executes this template with the supplied context and parent context, writing the results to
+ * the supplied writer. The parent context will be searched for variables that cannot be found
+ * in the main context, in the same way the main context becomes a parent context when entering
+ * a block.
+ * @throws MustacheException if an error occurs while executing or writing the template.
+ */
+ public void execute (Object context, Object parentContext, Writer out) throws MustacheException {
+ Context pctx = new Context(parentContext, null, 0, false, false);
+ executeSegs(new Context(context, pctx, 0, false, false), out);
+ }
+
+ /**
+ * Visits the tags in this template (via {@code visitor}) without executing it.
+ * @param visitor the visitor to be called back on each tag in the template.
+ */
+ public void visit (Mustache.Visitor visitor) {
+ for (Segment seg : _segs) {
+ seg.visit(visitor);
+ }
+ }
+
+ protected Template (Segment[] segs, Mustache.Compiler compiler) {
+ _segs = segs;
+ _compiler = compiler;
+ _fcache = compiler.collector.createFetcherCache();
+ }
+
+ protected void executeSegs (Context ctx, Writer out) throws MustacheException {
+ for (Segment seg : _segs) {
+ seg.execute(this, ctx, out);
+ }
+ }
+
+ protected Fragment createFragment (final Segment[] segs, final Context currentCtx) {
+ return new Fragment() {
+ @Override public void execute (Writer out) {
+ execute(currentCtx, out);
+ }
+ @Override public void execute (Object context, Writer out) {
+ execute(currentCtx.nest(context), out);
+ }
+ @Override public void executeTemplate (Template tmpl, Writer out) {
+ tmpl.executeSegs(currentCtx, out);
+ }
+ @Override public Object context () {
+ return currentCtx.data;
+ }
+ @Override public Object context (int n) {
+ return context(currentCtx, n);
+ }
+ @Override public StringBuilder decompile (StringBuilder into) {
+ for (Segment seg : segs) seg.decompile(_compiler.delims, into);
+ return into;
+ }
+ private Object context (Context ctx, int n) {
+ return (n == 0) ? ctx.data : context(ctx.parent, n-1);
+ }
+ private void execute (Context ctx, Writer out) {
+ for (Segment seg : segs) {
+ seg.execute(Template.this, ctx, out);
+ }
+ }
+ };
+ }
+
+ /**
+ * Called by executing segments to obtain the value of the specified variable in the supplied
+ * context.
+ *
+ * @param ctx the context in which to look up the variable.
+ * @param name the name of the variable to be resolved.
+ * @param missingIsNull whether to fail if a variable cannot be resolved, or to return null in
+ * that case.
+ *
+ * @return the value associated with the supplied name or null if no value could be resolved.
+ */
+ protected Object getValue (Context ctx, String name, int line, boolean missingIsNull) {
+ // handle our special variables
+ if (name.equals(FIRST_NAME)) {
+ return ctx.onFirst;
+ } else if (name.equals(LAST_NAME)) {
+ return ctx.onLast;
+ } else if (name.equals(INDEX_NAME)) {
+ return ctx.index;
+ }
+
+ // if we're in standards mode, restrict ourselves to simple direct resolution (no compound
+ // keys, no resolving values in parent contexts)
+ if (_compiler.standardsMode) {
+ Object value = getValueIn(ctx.data, name, line);
+ return checkForMissing(name, line, missingIsNull, value);
+ }
+
+ // first search our parent contexts for the key (even if the key is a compound key, we will
+ // first try to find it "whole" and only if that fails do we resolve it in parts)
+ for (Context pctx = ctx; pctx != null; pctx = pctx.parent) {
+ Object value = getValueIn(pctx.data, name, line);
+ if (value != NO_FETCHER_FOUND) return value;
+ }
+ // if we reach here, we found nothing in this or our parent contexts...
+
+ // if we have a compound key, decompose the value and resolve it step by step
+ if (!name.equals(DOT_NAME) && name.indexOf(DOT_NAME) != -1) {
+ return getCompoundValue(ctx, name, line, missingIsNull);
+ } else {
+ // otherwise let checkForMissing() decide what to do
+ return checkForMissing(name, line, missingIsNull, NO_FETCHER_FOUND);
+ }
+ }
+
+ /**
+ * Decomposes the compound key {@code name} into components and resolves the value they
+ * reference.
+ */
+ protected Object getCompoundValue (Context ctx, String name, int line, boolean missingIsNull) {
+ String[] comps = name.split("\\.");
+ // we want to allow the first component of a compound key to be located in a parent
+ // context, but once we're selecting sub-components, they must only be resolved in the
+ // object that represents that component
+ Object data = getValue(ctx, comps[0], line, missingIsNull);
+ for (int ii = 1; ii < comps.length; ii++) {
+ if (data == NO_FETCHER_FOUND) {
+ if (!missingIsNull) throw new MustacheException.Context(
+ "Missing context for compound variable '" + name + "' on line " + line +
+ ". '" + comps[ii - 1] + "' was not found.", name, line);
+ return null;
+ } else if (data == null) {
+ return null;
+ }
+ // once we step into a composite key, we drop the ability to query our parent contexts;
+ // that would be weird and confusing
+ data = getValueIn(data, comps[ii], line);
+ }
+ return checkForMissing(name, line, missingIsNull, data);
+ }
+
+ /**
+ * Returns the value of the specified variable, noting that it is intended to be used as the
+ * contents for a section.
+ */
+ protected Object getSectionValue (Context ctx, String name, int line) {
+ Object value = getValue(ctx, name, line, !_compiler.strictSections);
+ // TODO: configurable behavior on null values?
+ return (value == null) ? Collections.emptyList() : value;
+ }
+
+ /**
+ * Returns the value for the specified variable, or the configured default value if the
+ * variable resolves to null. See {@link #getValue}.
+ */
+ protected Object getValueOrDefault (Context ctx, String name, int line) {
+ Object value = getValue(ctx, name, line, _compiler.missingIsNull);
+ // getValue will raise MustacheException if a variable cannot be resolved and missingIsNull
+ // is not configured; so we're safe to assume that any null that makes it up to this point
+ // can be converted to nullValue
+ return (value == null) ? _compiler.computeNullValue(name) : value;
+ }
+
+ protected Object getValueIn (Object data, String name, int line) {
+ // if we're getting `.` or `this` then just return the whole context; we do this before the
+ // null check because it may be valid for the context to be null (if we're iterating over a
+ // list which contains nulls, for example)
+ if (isThisName(name)) return data;
+
+ if (data == null) {
+ throw new NullPointerException(
+ "Null context for variable '" + name + "' on line " + line);
+ }
+
+ Key key = new Key(data.getClass(), name);
+ Mustache.VariableFetcher fetcher = _fcache.get(key);
+ if (fetcher != null) {
+ try {
+ return fetcher.get(data, name);
+ } catch (Exception e) {
+ // zoiks! non-monomorphic call site, update the cache and try again
+ fetcher = _compiler.collector.createFetcher(data, key.name);
+ }
+ } else {
+ fetcher = _compiler.collector.createFetcher(data, key.name);
+ }
+
+ // if we were unable to create a fetcher, use the NOT_FOUND_FETCHER which will return
+ // NO_FETCHER_FOUND to let the caller know that they can try the parent context or do le
+ // freak out; we still cache this fetcher to avoid repeatedly looking up and failing to
+ // find a fetcher in the same context (which can be expensive)
+ if (fetcher == null) {
+ fetcher = NOT_FOUND_FETCHER;
+ }
+
+ try {
+ Object value = fetcher.get(data, name);
+ _fcache.put(key, fetcher);
+ return value;
+ } catch (Exception e) {
+ throw new MustacheException.Context(
+ "Failure fetching variable '" + name + "' on line " + line, name, line, e);
+ }
+ }
+
+ protected Object checkForMissing (String name, int line, boolean missingIsNull, Object value) {
+ if (value == NO_FETCHER_FOUND) {
+ if (missingIsNull) return null;
+ throw new MustacheException.Context(
+ "No method or field with name '" + name + "' on line " + line, name, line);
+ } else {
+ return value;
+ }
+ }
+
+ protected final Segment[] _segs;
+ protected final Mustache.Compiler _compiler;
+ protected final Map _fcache;
+
+ protected static class Context {
+ public final Object data;
+ public final Context parent;
+ public final int index;
+ public final boolean onFirst;
+ public final boolean onLast;
+
+ public Context (Object data, Context parent, int index, boolean onFirst, boolean onLast) {
+ this.data = data;
+ this.parent = parent;
+ this.index = index;
+ this.onFirst = onFirst;
+ this.onLast = onLast;
+ }
+
+ public Context nest (Object data) {
+ return new Context(data, this, index, onFirst, onLast);
+ }
+
+ public Context nest (Object data, int index, boolean onFirst, boolean onLast) {
+ return new Context(data, this, index, onFirst, onLast);
+ }
+ }
+
+ /** A template is broken into segments. */
+ protected static abstract class Segment {
+ abstract void execute (Template tmpl, Context ctx, Writer out);
+
+ abstract void decompile (Mustache.Delims delims, StringBuilder into);
+
+ abstract void visit (Mustache.Visitor visitor);
+
+ protected static void write (Writer out, String data) {
+ try {
+ out.write(data);
+ } catch (IOException ioe) {
+ throw new MustacheException(ioe);
+ }
+ }
+ }
+
+ /** Used to cache variable fetchers for a given context class, name combination. */
+ protected static class Key {
+ public final Class> cclass;
+ public final String name;
+
+ public Key (Class> cclass, String name) {
+ this.cclass = cclass;
+ this.name = name;
+ }
+
+ @Override public int hashCode () {
+ return cclass.hashCode() * 31 + name.hashCode();
+ }
+
+ @Override public boolean equals (Object other) {
+ Key okey = (Key)other;
+ return okey.cclass == cclass && okey.name.equals(name);
+ }
+
+ @Override public String toString () {
+ return cclass.getName() + ":" + name;
+ }
+ }
+
+ protected static boolean isThisName (String name) {
+ return DOT_NAME.equals(name) || THIS_NAME.equals(name);
+ }
+
+ protected static final String DOT_NAME = ".";
+ protected static final String THIS_NAME = "this";
+ protected static final String FIRST_NAME = "-first";
+ protected static final String LAST_NAME = "-last";
+ protected static final String INDEX_NAME = "-index";
+
+ /** A fetcher cached for lookups that failed to find a fetcher. */
+ protected static Mustache.VariableFetcher NOT_FOUND_FETCHER = new Mustache.VariableFetcher() {
+ public Object get (Object ctx, String name) throws Exception {
+ return NO_FETCHER_FOUND;
+ }
+ };
+}
diff --git a/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java
new file mode 100644
index 00000000..3bc40765
--- /dev/null
+++ b/lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/internal/InterpolatorTest.java
@@ -0,0 +1,145 @@
+package com.launchdarkly.sdk.server.ai.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+
+import com.launchdarkly.sdk.LDContext;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+
+@SuppressWarnings("javadoc")
+public class InterpolatorTest {
+ private final Interpolator interpolator = new Interpolator();
+
+ @Test
+ public void rendersSimpleVariable() {
+ Map vars = new HashMap<>();
+ vars.put("name", "World");
+ assertThat(interpolator.interpolate("Hello {{name}}", vars), is("Hello World"));
+ }
+
+ @Test
+ public void doesNotHtmlEscape() {
+ Map vars = new HashMap<>();
+ vars.put("x", "&\"'");
+ // Matches the JS/Python policy: values are emitted verbatim, never HTML-escaped.
+ assertThat(interpolator.interpolate("{{x}}", vars), is("&\"'"));
+ }
+
+ @Test
+ public void tripleStacheMatchesDoubleStache() {
+ Map vars = new HashMap<>();
+ vars.put("x", "");
+ assertThat(interpolator.interpolate("{{{x}}}", vars), is(interpolator.interpolate("{{x}}", vars)));
+ }
+
+ @Test
+ public void missingVariableRendersEmpty() {
+ assertThat(interpolator.interpolate("[{{missing}}]", new HashMap()), is("[]"));
+ }
+
+ @Test
+ public void nullVariableRendersEmpty() {
+ Map vars = new HashMap<>();
+ vars.put("x", null);
+ assertThat(interpolator.interpolate("[{{x}}]", vars), is("[]"));
+ }
+
+ @Test
+ public void nullTemplateReturnsNull() {
+ assertThat(interpolator.interpolate(null, new HashMap()), is(nullValue()));
+ assertThat(interpolator.interpolate(null, new HashMap(), LDContext.create("k")),
+ is(nullValue()));
+ }
+
+ @Test
+ public void exposesContextAsLdctx() {
+ LDContext context = LDContext.builder("user-key")
+ .name("Bob")
+ .set("tier", "gold")
+ .build();
+ String result = interpolator.interpolate(
+ "{{ldctx.kind}}/{{ldctx.key}}/{{ldctx.name}}/{{ldctx.tier}}", null, context);
+ assertThat(result, is("user/user-key/Bob/gold"));
+ }
+
+ @Test
+ public void exposesNestedCustomAttribute() {
+ LDContext context = LDContext.builder("user-key")
+ .set("address", com.launchdarkly.sdk.LDValue.buildObject().put("city", "Oakland").build())
+ .build();
+ assertThat(interpolator.interpolate("{{ldctx.address.city}}", null, context), is("Oakland"));
+ }
+
+ @Test
+ public void exposesMultiKindContextByKind() {
+ LDContext multi = LDContext.createMulti(
+ LDContext.builder("user-key").name("Bob").build(),
+ LDContext.builder(com.launchdarkly.sdk.ContextKind.of("org"), "org-key").set("tier", "gold").build());
+ String result = interpolator.interpolate(
+ "{{ldctx.kind}}/{{ldctx.user.key}}/{{ldctx.user.name}}/{{ldctx.org.tier}}", null, multi);
+ assertThat(result, is("multi/user-key/Bob/gold"));
+ }
+
+ @Test
+ public void multiKindNestedContextsOmitKind() {
+ LDContext multi = LDContext.createMulti(
+ LDContext.builder("user-key").build(),
+ LDContext.builder(com.launchdarkly.sdk.ContextKind.of("org"), "org-key").build());
+ // Standard LaunchDarkly context JSON omits "kind" on the per-kind objects of a multi-kind
+ // context, so {{ldctx.user.kind}} renders empty rather than echoing the kind name.
+ assertThat(
+ interpolator.interpolate("[{{ldctx.user.kind}}]", null, multi), is("[]"));
+ }
+
+ @Test
+ public void anonymousIsAlwaysExposedAsBoolean() {
+ LDContext anon = LDContext.builder("k").anonymous(true).build();
+ assertThat(interpolator.interpolate("{{ldctx.anonymous}}", null, anon), is("true"));
+ // Matches other SDKs: anonymous is emitted even when false, rather than rendering empty.
+ LDContext named = LDContext.builder("k").build();
+ assertThat(interpolator.interpolate("{{ldctx.anonymous}}", null, named), is("false"));
+ }
+
+ @Test
+ public void multiKindExposesFullyQualifiedKeyAtTopLevel() {
+ LDContext multi = LDContext.createMulti(
+ LDContext.builder("user-key").build(),
+ LDContext.builder(com.launchdarkly.sdk.ContextKind.of("org"), "org-key").build());
+ // {{ldctx.key}} on a multi-kind context resolves to the canonical fully-qualified key.
+ assertThat(
+ interpolator.interpolate("{{ldctx.key}}", null, multi), is(multi.getFullyQualifiedKey()));
+ }
+
+ @Test
+ public void ldctxOverridesUserSuppliedValue() {
+ Map userLdctx = new HashMap<>();
+ userLdctx.put("key", "WRONG");
+ Map vars = new HashMap<>();
+ vars.put("ldctx", userLdctx);
+
+ LDContext context = LDContext.create("right-key");
+ assertThat(interpolator.interpolate("{{ldctx.key}}", vars, context), is("right-key"));
+ }
+
+ @Test
+ public void nullContextLeavesLdctxEmpty() {
+ assertThat(interpolator.interpolate("[{{ldctx.key}}]", null, null), is("[]"));
+ }
+
+ @Test
+ public void cachedTemplateRendersConsistentlyAcrossInvocations() {
+ Map first = new HashMap<>();
+ first.put("v", "one");
+ Map second = new HashMap<>();
+ second.put("v", "two");
+
+ assertThat(interpolator.interpolate("value={{v}}", first), is("value=one"));
+ // Second render uses the cached compiled template but the new variable map.
+ assertThat(interpolator.interpolate("value={{v}}", second), is("value=two"));
+ }
+}