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: + * + *

+ * 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")); + } +}