package com.onthegomap.planetiler.custommap.expression; import com.onthegomap.planetiler.custommap.TypeConversion; import com.onthegomap.planetiler.custommap.expression.stdlib.PlanetilerStdLib; import com.onthegomap.planetiler.util.Memoized; import com.onthegomap.planetiler.util.Try; import java.util.Objects; import java.util.regex.Pattern; import org.projectnessie.cel.extension.StringsLib; import org.projectnessie.cel.tools.Script; import org.projectnessie.cel.tools.ScriptCreateException; import org.projectnessie.cel.tools.ScriptException; import org.projectnessie.cel.tools.ScriptHost; /** * An expression that returns the result of evaluating a user-defined string script on the input environment context. * * @param Type of the context that the script is expecting * @param Result type of the script */ public class ConfigExpressionScript implements ConfigExpression { private static final Pattern EXPRESSION_PATTERN = Pattern.compile("^\\s*\\$\\{(.*)}\\s*$"); private static final Pattern ESCAPED_EXPRESSION_PATTERN = Pattern.compile("^\\s*\\\\+\\$\\{(.*)}\\s*$"); private static final Memoized, ?> staticEvaluationCache = Memoized.memoize(ConfigExpressionScript::doStaticEvaluate); private final Script script; private final Class returnType; private final String scriptText; private final ScriptEnvironment descriptor; private ConfigExpressionScript(String scriptText, Script script, ScriptEnvironment descriptor, Class returnType) { this.scriptText = scriptText; this.script = script; this.returnType = returnType; this.descriptor = descriptor; } /** Returns true if this is a string expression like {@code "${ ... }"} */ public static boolean isScript(Object obj) { if (obj instanceof String string) { var matcher = EXPRESSION_PATTERN.matcher(string); return matcher.matches(); } return false; } /** * Returns true if this is an escaped string expression that should just be treated as a string like {@code "\${ ... * }"} */ public static boolean isEscapedScript(Object obj) { if (obj instanceof String string) { var matcher = ESCAPED_EXPRESSION_PATTERN.matcher(string); return matcher.matches(); } return false; } /** * Removes script escape character from a string {@code "\${ ... }"} becomes {@code "${ ... }"} */ public static Object unescape(Object obj) { if (isEscapedScript(obj)) { return obj.toString().replaceFirst("\\\\\\$", "\\$"); } else { return obj; } } /** * Returns the script text between the {@code "${ ... }"} characters. */ public static String extractScript(Object obj) { if (obj instanceof String string) { var matcher = EXPRESSION_PATTERN.matcher(string); if (matcher.matches()) { return matcher.group(1); } } return null; } /** * Returns an expression parsed from a user-supplied script string. * * @throws ParseException if the script failes to compile or type-check */ public static ConfigExpressionScript parse(String string, ScriptEnvironment description) { return parse(string, description, Object.class); } /** * Returns an expression parsed from a user-supplied script string that coerces the result to {@code O}. * * @throws ParseException if the script failes to compile or type-check */ public static ConfigExpressionScript parse(String string, ScriptEnvironment description, Class expected) { ScriptHost scriptHost = ScriptHost.newBuilder().build(); try { var scriptBuilder = scriptHost.buildScript(string).withLibraries( new StringsLib(), new PlanetilerStdLib() ); if (!description.declarations().isEmpty()) { scriptBuilder.withDeclarations(description.declarations()); } var script = scriptBuilder.build(); return new ConfigExpressionScript<>(string, script, description, expected); } catch (ScriptCreateException e) { throw new ParseException(string, e); } } @Override public O apply(I input) { try { return TypeConversion.convert(script.execute(Object.class, input), returnType); } catch (ScriptException e) { throw new EvaluationException("Error evaluating script '%s'".formatted(scriptText), e); } } @Override public boolean equals(Object o) { // ignore the parsed script object return this == o || (o instanceof ConfigExpressionScript config && returnType.equals(config.returnType) && scriptText.equals(config.scriptText) && descriptor.equals(config.descriptor)); } @Override public int hashCode() { // ignore the parsed script object return Objects.hash(returnType, scriptText, descriptor); } /** * Attempts to parse and evaluate this script in an environment with no variables. *

* If this returns {@link Try.Success} then it means this script will always return the same constant value and we can * avoid evaluating it at runtime. */ public Try tryStaticEvaluate() { // type checking can be expensive when run hundreds of times simplifying expressions iteratively and it never // changes for a given script and input environment, so cache results between calls. return staticEvaluationCache.tryApply(this, returnType); } private O doStaticEvaluate() { return ConfigExpressionScript.parse(scriptText, descriptor.root().description(), returnType) .apply(descriptor.root()); } @Override public String toString() { return "ConfigExpression[returnType=" + returnType + ", scriptText='" + scriptText + '\'' + ']'; } @Override public ConfigExpression simplifyOnce() { var result = tryStaticEvaluate(); if (result.isSuccess()) { return ConfigExpression.constOf(result.get()); } else if (descriptor.containsVariable(scriptText.strip())) { return ConfigExpression.variable(ConfigExpression.signature(descriptor, returnType), scriptText.strip()); } return this; } }