From fdada5272224b7de832c783b9d37ae132f0e96e6 Mon Sep 17 00:00:00 2001 From: Farrael Date: Mon, 2 Feb 2026 21:30:31 +0100 Subject: [PATCH 01/30] refactor: use ast for template processing (wip) --- build.gradle | 1 + .../java/au/ellie/hyui/HyUIPluginLogger.java | 4 + .../au/ellie/hyui/html/TemplateProcessor.java | 1049 ++--------------- .../au/ellie/hyui/html/ast/Evaluator.java | 342 ++++++ .../java/au/ellie/hyui/html/ast/Lexer.java | 392 ++++++ .../java/au/ellie/hyui/html/ast/Parser.java | 366 ++++++ .../hyui/html/ast/context/FilterRegistry.java | 87 ++ .../hyui/html/ast/context/VariableStack.java | 96 ++ .../au/ellie/hyui/html/ast/item/Node.java | 95 ++ .../au/ellie/hyui/html/ast/item/Token.java | 48 + .../hyui/html/ast/utils/NumericUtils.java | 107 ++ .../hyui/html/ast/utils/ReflectionUtils.java | 72 ++ .../hyui/utils/multiplehud/MultipleHUD.java | 2 - .../hyui/html/TemplateProcessorTest.java | 940 +++++++++++---- .../hyui/html/ast/utils/NumericUtilsTest.java | 66 ++ 15 files changed, 2535 insertions(+), 1132 deletions(-) create mode 100644 src/main/java/au/ellie/hyui/html/ast/Evaluator.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/Lexer.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/Parser.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/item/Node.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/item/Token.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java create mode 100644 src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java create mode 100644 src/test/java/au/ellie/hyui/html/ast/utils/NumericUtilsTest.java diff --git a/build.gradle b/build.gradle index 7809504..0e8ed96 100644 --- a/build.gradle +++ b/build.gradle @@ -110,6 +110,7 @@ tasks.test { jvmArgs "-Djava.util.logging.manager=com.hypixel.hytale.logger.backend.HytaleLogManager" testLogging { + showStandardStreams = true events "passed", "failed", "skipped" } } \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/HyUIPluginLogger.java b/src/main/java/au/ellie/hyui/HyUIPluginLogger.java index e6d5171..7c78661 100644 --- a/src/main/java/au/ellie/hyui/HyUIPluginLogger.java +++ b/src/main/java/au/ellie/hyui/HyUIPluginLogger.java @@ -11,6 +11,10 @@ public class HyUIPluginLogger { public HyUIPluginLogger() { } + + public void logWarn(String message) { + internalLogger.atWarning().log(message); + } public void logFinest(String message) { internalLogger.atFinest().log(message); diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index da7c98a..efd218a 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -1,21 +1,28 @@ package au.ellie.hyui.html; -import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.events.UIContext; import au.ellie.hyui.builders.UIElementBuilder; - -import java.lang.reflect.Array; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; +import au.ellie.hyui.events.UIContext; +import au.ellie.hyui.html.ast.Evaluator; +import au.ellie.hyui.html.ast.Lexer; +import au.ellie.hyui.html.ast.Parser; +import au.ellie.hyui.html.ast.context.FilterRegistry; +import au.ellie.hyui.html.ast.context.VariableStack; +import au.ellie.hyui.html.ast.item.Node; +import au.ellie.hyui.html.ast.item.Token; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.function.Function; import java.util.function.Supplier; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Preprocessor for HyUIML templates that supports variable interpolation and component inclusion. @@ -49,40 +56,14 @@ */ public class TemplateProcessor { - // Pattern for {{$variable}} or {{$variable|default}} or {{$variable|filter}} - private static final Pattern VARIABLE_PATTERN = Pattern.compile( - "\\{\\{\\$([a-zA-Z_][a-zA-Z0-9_]*(?:\\.[a-zA-Z_][a-zA-Z0-9_]*)*)(?:\\|([^}]*))?\\}\\}" - ); - - private static final String EACH_START = "{{#each"; - private static final String EACH_END = "{{/each}}"; - private static final String IF_START = "{{#if"; - private static final String IF_END = "{{/if}}"; - private static final String ELSE_TAG = "{{else}}"; - private static final int MAX_COMPONENT_DEPTH = 20; + private static final Object NULL_SENTINEL = new Object(); private final Map variables = new HashMap<>(); private final Map components = new HashMap<>(); - private final Map> filters = new HashMap<>(); + private final FilterRegistry filterRegistry = new FilterRegistry(); private ValueResolver valueResolver; - private static final Object NULL_SENTINEL = new Object(); private boolean preferDynamicValues; - @FunctionalInterface - public interface ValueResolver { - Optional resolve(String name); - } - - public TemplateProcessor() { - // Register default filters - registerFilter("upper", String::toUpperCase); - registerFilter("lower", String::toLowerCase); - registerFilter("trim", String::trim); - registerFilter("capitalize", this::capitalize); - registerFilter("number", this::formatNumber); - registerFilter("percent", this::formatPercent); - } - /** * Sets a template variable from any object. * @@ -91,12 +72,16 @@ public TemplateProcessor() { * @return This processor for chaining */ public TemplateProcessor setVariable(String name, Object value) { + if (value instanceof Function) + throw new RuntimeException("Use the Function overload to set a variable from a function."); + variables.put(name, value); return this; } /** - * Sets a template variable from any object. + * Sets a template variable from a supplier. + * Resolved at processing time and cached for the duration of the processing. * * @param name Variable name (without $) * @param value Supplier that provides the variable value @@ -108,16 +93,39 @@ public TemplateProcessor setVariable(String name, Supplier value) { } /** - * Sets multiple variables at once. + * Sets a template variable from a function. + * Resolved at processing time with access to the variable stack, not cached. + * + * @param name Variable name (without $) + * @param value Function that provides the variable value + * @return This processor for chaining + */ + public TemplateProcessor setVariable(String name, Function value) { + variables.put(name, value); + return this; + } + + /** + * Sets multiple template variables at once. * * @param vars Map of variable names to values * @return This processor for chaining */ - @SuppressWarnings("unchecked") public TemplateProcessor setVariables(Map vars) { - for (Map.Entry entry : vars.entrySet()) { + for (Map.Entry entry : vars.entrySet()) setVariable(entry.getKey(), entry.getValue()); - } + return this; + } + + /** + * Register a new filter. + * + * @param name The name of the filter. + * @param filter The filter implementation. + */ + public TemplateProcessor registerFilter(String name, FilterRegistry.Filter filter) { + filterRegistry.register(name, filter); + return this; } @@ -134,933 +142,132 @@ public TemplateProcessor registerComponent(String name, String template) { } /** - * Registers a custom filter function. + * Registers a reusable component template loaded from resources. * - * @param name Filter name - * @param filter Filter function + * @param name Component name (e.g., "button", "card") + * @param resourcePath Resource path to the component HTML - located in Common/UI/Custom/. * @return This processor for chaining */ - public TemplateProcessor registerFilter(String name, Function filter) { - filters.put(name, filter); - return this; + public TemplateProcessor registerComponentFromFile(String name, String resourcePath) { + if (resourcePath == null || resourcePath.isBlank()) + throw new IllegalArgumentException("Resource path cannot be null or blank."); + + String trimmed = resourcePath.startsWith("/") ? resourcePath.substring(1) : resourcePath; + String template = loadHtmlFromResources("/Common/UI/Custom/" + trimmed); + return registerComponent(name, template); } /** - * Processes the template, substituting variables and including components. + * Process a template with the current variables. * - * @param template The template string - * @return Processed HTML string + * @param template The template string. + * @return The processed template. */ public String process(String template) { - return processTemplate(template, new HashMap<>(variables), 0); + return process(template, (Map) null); } /** * Processes the template using the provided UI context to resolve element IDs. * * @param template The template string - * @param context The UI context for runtime values + * @param context The UI context for runtime values * @return Processed HTML string */ - public String process(String template, UIContext context) { + public String process(String template, @Nullable UIContext context) { ValueResolver previousResolver = this.valueResolver; boolean previousPreferDynamic = this.preferDynamicValues; this.valueResolver = name -> { - if (context == null) { + if (context == null) return Optional.empty(); - } + Optional value = context.getValue(name); - if (value.isPresent()) { + if (value.isPresent()) return value; - } + return hasElement(context, name) ? Optional.of(NULL_SENTINEL) : Optional.empty(); }; - this.preferDynamicValues = true; + + this.preferDynamicValues = true; try { - return processTemplate(template, new HashMap<>(variables), 0); + return process(template, new HashMap<>(variables)); } finally { this.valueResolver = previousResolver; this.preferDynamicValues = previousPreferDynamic; } } - private String processTemplate(String template, Map scope, int componentDepth) { - String result = template; - - // Process control structures first so false branches aren't expanded. - result = processEachBlocks(result, scope, componentDepth); - result = processIfBlocks(result, scope, componentDepth); - - // Expand components with the current scope. - result = processComponents(result, scope, componentDepth); - - // Process control structures again for blocks inside component templates. - result = processEachBlocks(result, scope, componentDepth); - result = processIfBlocks(result, scope, componentDepth); - - // Then process variables. - result = processVariables(result, scope); - - return result; - } - - private String processVariables(String template, Map scope) { - Matcher matcher = VARIABLE_PATTERN.matcher(template); - StringBuilder result = new StringBuilder(); - - while (matcher.find()) { - String varName = matcher.group(1); - String filterOrDefault = matcher.group(2); - - Object rawValue = resolveVariable(scope, varName); - String value = rawValue != null ? String.valueOf(rawValue) : ""; - - // Apply filter or use default value - if (filterOrDefault != null && !filterOrDefault.isEmpty()) { - if (filters.containsKey(filterOrDefault)) { - // It's a filter - value = filters.get(filterOrDefault).apply(value); - } else if (value.isEmpty()) { - // It's a default value - value = filterOrDefault; - } - } - - HyUIPlugin.getLog().logFinest("Template variable: $" + varName + " = " + value); - matcher.appendReplacement(result, Matcher.quoteReplacement(value)); - } - matcher.appendTail(result); - - return result.toString(); - } - - private String processEachBlocks(String template, Map scope, int componentDepth) { - StringBuilder result = new StringBuilder(); - int index = 0; - - while (true) { - int start = template.indexOf(EACH_START, index); - if (start < 0) { - result.append(template.substring(index)); - break; - } - - result.append(template, index, start); - - int startClose = template.indexOf("}}", start); - if (startClose < 0) { - result.append(template.substring(start)); - break; - } - - String listName = template.substring(start + EACH_START.length(), startClose).trim(); - int end = findMatchingEnd(template, startClose + 2, EACH_START, EACH_END); - if (end < 0) { - result.append(template.substring(start)); - break; - } - - String inner = template.substring(startClose + 2, end); - Object listObj = resolveVariable(scope, listName); - Iterable items = toIterable(listObj); - - for (Object item : items) { - Map childScope = new HashMap<>(scope); - - // Ignore primitive types for model variable extraction - if (!item.getClass().isPrimitive()) { - childScope.putAll(extractModelVariables(item)); - } - - childScope.put("item", item); - result.append(processTemplate(inner, childScope, componentDepth)); - } - - index = end + EACH_END.length(); - } - - return result.toString(); - } - - private String processIfBlocks(String template, Map scope, int componentDepth) { - StringBuilder result = new StringBuilder(); - int index = 0; - - while (true) { - int start = template.indexOf(IF_START, index); - if (start < 0) { - result.append(template.substring(index)); - break; - } - - result.append(template, index, start); - - int startClose = template.indexOf("}}", start); - if (startClose < 0) { - result.append(template.substring(start)); - break; - } - - String conditionName = template.substring(start + IF_START.length(), startClose).trim(); - int end = findMatchingEnd(template, startClose + 2, IF_START, IF_END); - if (end < 0) { - result.append(template.substring(start)); - break; - } - - int elseIndex = findElseIndex(template, startClose + 2, end); - String trueBlock; - String falseBlock = ""; - - if (elseIndex >= 0) { - trueBlock = template.substring(startClose + 2, elseIndex); - falseBlock = template.substring(elseIndex + ELSE_TAG.length(), end); - } else { - trueBlock = template.substring(startClose + 2, end); - } - - boolean conditionResult = evaluateCondition(conditionName, scope); - String chosen = conditionResult ? trueBlock : falseBlock; - result.append(processTemplate(chosen, scope, componentDepth)); - - index = end + IF_END.length(); - } - - return result.toString(); - } - - private String processComponents(String template, Map scope, int componentDepth) { - StringBuilder result = new StringBuilder(); - int index = 0; - - while (true) { - int start = template.indexOf("{{@", index); - if (start < 0) { - result.append(template.substring(index)); - break; - } - - result.append(template, index, start); - int cursor = start + 3; - int depth = 1; - - while (cursor < template.length()) { - if (template.startsWith("{{", cursor)) { - depth++; - cursor += 2; - continue; - } - if (template.startsWith("}}", cursor)) { - depth--; - if (depth == 0) { - break; - } - cursor += 2; - continue; - } - cursor++; - } - - if (depth != 0) { - result.append(template.substring(start)); - break; - } - - String content = template.substring(start + 3, cursor).trim(); - String componentName; - String paramsStr = null; - int colonIndex = content.indexOf(':'); - if (colonIndex >= 0) { - componentName = content.substring(0, colonIndex).trim(); - paramsStr = content.substring(colonIndex + 1).trim(); - } else { - componentName = content.trim(); - } - - String componentHtml = components.get(componentName); - if (componentHtml == null) { - HyUIPlugin.getLog().logFinest("Unknown component: @" + componentName); - result.append(""); - index = cursor + 2; - continue; - } - - if (paramsStr != null && !paramsStr.isEmpty()) { - Map params = parseParams(paramsStr); - for (Map.Entry param : params.entrySet()) { - String rawValue = param.getValue(); - String value = processVariables(rawValue, scope); - HyUIPlugin.getLog().logFinest("Component param @" + componentName + " " + param.getKey() - + " raw=" + rawValue + " -> " + value + " scope=" + scope.keySet()); - componentHtml = componentHtml.replace("{{$" + param.getKey() + "}}", value); - } - } - - HyUIPlugin.getLog().logFinest("Including component: @" + componentName); - if (componentDepth >= MAX_COMPONENT_DEPTH) { - HyUIPlugin.getLog().logFinest("Component recursion limit hit for @" + componentName); - result.append(""); - } else { - result.append(processTemplate(componentHtml, scope, componentDepth + 1)); - } - index = cursor + 2; - } - - return result.toString(); - } - - private Map parseParams(String paramsStr) { - Map params = new HashMap<>(); - for (String param : paramsStr.split(",")) { - String[] parts = param.trim().split("=", 2); - if (parts.length == 2) { - params.put(parts[0].trim(), parts[1].trim()); - } - } - return params; - } - - private boolean evaluateCondition(String rawCondition, Map scope) { - String condition = rawCondition != null ? rawCondition.trim() : ""; - if (condition.isEmpty()) { - return false; - } - - return evaluateLogical(condition, scope); - } - - private boolean evaluateLogical(String condition, Map scope) { - for (String orPart : splitByOperator(condition, "||")) { - if (evaluateAnd(orPart, scope)) { - return true; - } - } - return false; - } - - private boolean evaluateAnd(String condition, Map scope) { - for (String andPart : splitByOperator(condition, "&&")) { - if (!evaluateUnary(andPart, scope)) { - return false; - } - } - return true; - } - - private boolean evaluateUnary(String condition, Map scope) { - String trimmed = condition.trim(); - if (trimmed.startsWith("!")) { - return !evaluateUnary(trimmed.substring(1), scope); - } - - return evaluateComparison(trimmed, scope); - } - - private boolean evaluateComparison(String condition, Map scope) { - Matcher containsMatcher = Pattern.compile("(.+?)\\s+contains\\s+(.+)").matcher(condition); - if (containsMatcher.matches()) { - Object left = resolveOperand(containsMatcher.group(1).trim(), scope); - Object right = resolveOperand(containsMatcher.group(2).trim(), scope); - return containsValue(left, right); - } - - Matcher matcher = Pattern.compile("(.+?)(==|!=|>=|<=|>|<)(.+)").matcher(condition); - if (matcher.matches()) { - Object left = resolveOperand(matcher.group(1).trim(), scope); - Object right = resolveOperand(matcher.group(3).trim(), scope); - String operator = matcher.group(2); - return compareValues(left, right, operator); - } - - Object value = resolveOperand(condition, scope); - return isTruthy(value); - } - - private Object resolveOperand(String token, Map scope) { - if (token == null) { - return null; - } - String trimmed = token.trim(); - if (trimmed.isEmpty()) { - return ""; - } - - if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) - || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { - return trimmed.substring(1, trimmed.length() - 1); - } - - if ("null".equalsIgnoreCase(trimmed)) { - return null; - } - - if ("true".equalsIgnoreCase(trimmed) || "false".equalsIgnoreCase(trimmed)) { - return Boolean.parseBoolean(trimmed); - } - - try { - if (trimmed.contains(".")) { - return Double.parseDouble(trimmed); - } - return Long.parseLong(trimmed); - } catch (NumberFormatException ignored) { - // Not a number literal. - } - - if (hasVariable(scope, trimmed)) { - return resolveVariable(scope, trimmed); - } - - return trimmed; - } - - private boolean compareValues(Object left, Object right, String operator) { - if (left == null || right == null) { - if ("==".equals(operator)) { - return left == right; - } - if ("!=".equals(operator)) { - return left != right; - } - return false; - } - - Double leftNum = toNumber(left); - Double rightNum = toNumber(right); - if (leftNum != null && rightNum != null) { - return switch (operator) { - case "==" -> Double.compare(leftNum, rightNum) == 0; - case "!=" -> Double.compare(leftNum, rightNum) != 0; - case ">" -> leftNum > rightNum; - case "<" -> leftNum < rightNum; - case ">=" -> leftNum >= rightNum; - case "<=" -> leftNum <= rightNum; - default -> false; - }; - } - - if (left instanceof Boolean || right instanceof Boolean) { - boolean leftVal = left instanceof Boolean ? (Boolean) left : Boolean.parseBoolean(left.toString()); - boolean rightVal = right instanceof Boolean ? (Boolean) right : Boolean.parseBoolean(right.toString()); - return switch (operator) { - case "==" -> leftVal == rightVal; - case "!=" -> leftVal != rightVal; - default -> false; - }; - } - - String leftStr = String.valueOf(left); - String rightStr = String.valueOf(right); - return switch (operator) { - case "==" -> leftStr.equals(rightStr); - case "!=" -> !leftStr.equals(rightStr); - default -> false; - }; - } - - private Double toNumber(Object value) { - if (value instanceof Number number) { - return number.doubleValue(); - } - try { - return Double.parseDouble(value.toString()); - } catch (NumberFormatException e) { - return null; - } - } - - private boolean containsValue(Object left, Object right) { - if (left == null || right == null) { - return false; - } - - if (left instanceof CharSequence seq) { - return seq.toString().contains(String.valueOf(right)); - } - - if (left instanceof Map map) { - return map.containsKey(right); - } - - if (left instanceof Iterable iterable) { - for (Object item : iterable) { - if (item == null && right == null) { - return true; - } - if (item != null && item.equals(right)) { - return true; - } - } - return false; - } - - if (left.getClass().isArray()) { - int length = Array.getLength(left); - for (int i = 0; i < length; i++) { - Object item = Array.get(left, i); - if (item == null && right == null) { - return true; - } - if (item != null && item.equals(right)) { - return true; - } - } - return false; - } - - return left.toString().contains(String.valueOf(right)); - } - - private List splitByOperator(String input, String operator) { - List parts = new java.util.ArrayList<>(); - boolean inSingle = false; - boolean inDouble = false; - int start = 0; - - for (int i = 0; i < input.length(); i++) { - char c = input.charAt(i); - if (c == '"' && !inSingle) { - inDouble = !inDouble; - continue; - } - if (c == '\'' && !inDouble) { - inSingle = !inSingle; - continue; - } - - if (!inSingle && !inDouble && input.startsWith(operator, i)) { - parts.add(input.substring(start, i)); - start = i + operator.length(); - i += operator.length() - 1; - } - } - - parts.add(input.substring(start)); - return parts; - } - - private boolean hasVariable(Map scope, String name) { - if (name == null || name.isBlank()) { - return false; - } - - if (scope.containsKey(name)) { - return true; - } - - Optional resolved = resolveDynamicValue(name); - if (resolved.isPresent()) { - return true; - } - - int dotIndex = name.indexOf('.'); - if (dotIndex > 0) { - String root = name.substring(0, dotIndex); - return scope.containsKey(root); - } - - return false; - } - - private Object resolveVariable(Map scope, String name) { - if (name == null || name.isBlank()) { - return null; - } - - if (preferDynamicValues) { - Optional resolved = resolveDynamicValue(name); - if (resolved.isPresent() && resolved.get() != NULL_SENTINEL) { - return resolved.get(); - } - } - - if (scope.containsKey(name)) { - var value = scope.get(name); - return value instanceof Supplier supplier ? supplier.get() : value; - } - - Optional resolved = resolveDynamicValue(name); - if (resolved.isPresent()) { - Object value = resolved.get(); - return value == NULL_SENTINEL ? null : value; - } - - String[] path = name.split("\\."); - if (path.length == 0) { - return null; - } - - String first = path[0]; - if (!scope.containsKey(first)) { - return null; - } - - Object current = scope.get(first); - if (current instanceof Supplier supplier) - current = supplier.get(); - - for (int i = 1; i < path.length; i++) { - if (current == null) { - return null; - } - current = getPropertyValue(current, path[i]); - } - - return current; - } - - private Optional resolveDynamicValue(String name) { - if (valueResolver == null) { - return Optional.empty(); - } - return valueResolver.resolve(name); - } + public String process(String template, @Nullable Map additionalVariables) { + // Lexer / Parser + List tokens = new Lexer(template).tokenize(); + List ast = new Parser(tokens).parse(); - @SuppressWarnings({"rawtypes", "unchecked"}) - private boolean hasElement(UIContext context, String name) { - return context.getById(name, (Class) UIElementBuilder.class).isPresent(); - } + // Inject additional variables, this allows for per-call variable overrides + Map parameters = additionalVariables == null ? variables : new HashMap<>(variables); + if (additionalVariables != null) + parameters.putAll(additionalVariables); - private Iterable toIterable(Object value) { - if (value == null) { - return List.of(); - } - if (value instanceof Iterable iterable) { - return iterable; - } - if (value.getClass().isArray()) { - int length = Array.getLength(value); - List list = new java.util.ArrayList<>(length); - for (int i = 0; i < length; i++) { - list.add(Array.get(value, i)); - } - return list; - } - return List.of(); + // Evaluator + return new Evaluator(parameters, filterRegistry).evaluate(ast); } - private int findMatchingEnd(String template, int searchFrom, String startTag, String endTag) { - int depth = 1; - int index = searchFrom; - - while (index < template.length()) { - int nextStart = template.indexOf(startTag, index); - int nextEnd = template.indexOf(endTag, index); - - if (nextEnd < 0) { - return -1; - } - - if (nextStart != -1 && nextStart < nextEnd) { - depth++; - index = nextStart + startTag.length(); - } else { - depth--; - if (depth == 0) { - return nextEnd; - } - index = nextEnd + endTag.length(); - } - } - - return -1; - } + // ===== Internal ===== - private int findElseIndex(String template, int searchFrom, int endIndex) { - int depth = 1; - int index = searchFrom; - - while (index < endIndex) { - int nextStart = template.indexOf(IF_START, index); - int nextEnd = template.indexOf(IF_END, index); - int nextElse = template.indexOf(ELSE_TAG, index); - - int next = minPositive(nextStart, nextEnd, nextElse); - if (next < 0 || next >= endIndex) { - return -1; - } - - if (next == nextStart) { - depth++; - index = nextStart + IF_START.length(); - } else if (next == nextEnd) { - depth--; - if (depth == 0) { - return -1; - } - index = nextEnd + IF_END.length(); - } else { - if (depth == 1) { - return nextElse; - } - index = nextElse + ELSE_TAG.length(); - } - } - - return -1; - } - - private int minPositive(int... values) { - int min = Integer.MAX_VALUE; - for (int value : values) { - if (value >= 0 && value < min) { - min = value; - } - } - return min == Integer.MAX_VALUE ? -1 : min; - } - - private Map extractModelVariables(Object item) { - Map values = new HashMap<>(); - if (item == null) { - return values; - } - - if (item instanceof Map map) { - for (Map.Entry entry : map.entrySet()) { - if (entry.getKey() instanceof String key) { - values.put(key, entry.getValue()); - } - } - return values; - } - - for (Field field : item.getClass().getFields()) { - if (values.containsKey(field.getName())) { - continue; - } - extractVarsFromField(item, values, field); - } - - for (Method method : item.getClass().getMethods()) { - extractVarsFromMethod(item, values, method); - } - - for (Field field : item.getClass().getDeclaredFields()) { - if (field.isSynthetic() || values.containsKey(field.getName())) { - continue; - } - extractVarsFromField(item, values, field); - } - - for (Method method : item.getClass().getDeclaredMethods()) { - extractVarsFromMethod(item, values, method); - } - - return values; - } - - private void extractVarsFromField(Object item, Map values, Field field) { - try { - field.setAccessible(true); - } catch (Exception ignored) { - // For whatever reason we can't access it, ignore the field. - return; - } - - values.put(field.getName(), (Supplier)() -> { - try { - return field.get(Modifier.isStatic(field.getModifiers()) ? null : item); - } catch (IllegalAccessException | IllegalArgumentException ignored) { - return ""; - } - }); - } - - private void extractVarsFromMethod(Object item, Map values, Method method) { - if (method.getParameterCount() != 0) { - return; - } - String name = method.getName(); - if (name.equals("getClass")) { - return; - } - - String propName = null; - if (name.startsWith("get") && name.length() > 3) { - propName = decapitalize(name.substring(3)); - } else if (name.startsWith("is") && name.length() > 2) { - propName = decapitalize(name.substring(2)); - } - - if (propName != null && !values.containsKey(propName)) { - try { - method.setAccessible(true); - } catch (Exception ignored) { - // For whatever reason we can't access it, ignore the method. - return; - } - - values.put(propName, (Supplier)() -> { + /** + * Load HTML content from resource files. + * + * @param resourceFileName The resource file name/path. + * @return The HTML content as a string. + */ + private String loadHtmlFromResources(String resourceFileName) { + if (resourceFileName == null || resourceFileName.isBlank()) + throw new IllegalArgumentException("Resource path cannot be null or blank."); + + String normalized = resourceFileName.startsWith("/") ? resourceFileName.substring(1) : resourceFileName; + List candidatePaths = List.of( + Paths.get("src/main/resources").resolve(normalized), + Paths.get("..", "src", "main", "resources").resolve(normalized), + Paths.get("build/resources/main").resolve(normalized), + Paths.get("..", "build", "resources", "main").resolve(normalized), + Paths.get(normalized) + ); + + for (Path path : candidatePaths) { + if (Files.isRegularFile(path)) { try { - return method.invoke(Modifier.isStatic(method.getModifiers()) ? null : item); - } catch (Exception ignored) { - return ""; + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to load HTML from file: " + path, e); } - }); - } - } - - private Object getPropertyValue(Object target, String name) { - if (target == null || name == null || name.isBlank()) { - return null; - } - - if (target instanceof Map map) { - return map.get(name); - } - - if (target instanceof List list) { - Integer index = parseIndex(name); - if (index != null && index >= 0 && index < list.size()) { - return list.get(index); } - return null; } - if (target.getClass().isArray()) { - Integer index = parseIndex(name); - if (index != null && index >= 0 && index < Array.getLength(target)) { - return Array.get(target, index); - } - return null; - } + String resourceLookup = resourceFileName.startsWith("/") ? resourceFileName : "/" + resourceFileName; + try (InputStream inputStream = TemplateProcessor.class.getResourceAsStream(resourceLookup)) { + if (inputStream == null) + throw new IllegalArgumentException("Resource not found: " + resourceFileName); - try { - Field field = target.getClass().getField(name); - if (!field.canAccess(target)) { - field.setAccessible(true); - } - return field.get(target); - } catch (NoSuchFieldException | IllegalAccessException ignored) { - // Fall back to getters. - } - - String suffix = name.substring(0, 1).toUpperCase() + name.substring(1); - for (String prefix : new String[] {"get", "is"}) { - try { - Method method = target.getClass().getMethod(prefix + suffix); - if (method.getParameterCount() == 0) { - if (!method.canAccess(target)) { - method.setAccessible(true); - } - return method.invoke(target); - } - } catch (Exception ignored) { - // Try next getter. - } - } - - try { - Field field = target.getClass().getDeclaredField(name); - if (!field.canAccess(target)) { - field.setAccessible(true); - } - return field.get(target); - } catch (NoSuchFieldException | IllegalAccessException ignored) { - // Ignore. - } - - for (String prefix : new String[] {"get", "is"}) { - try { - Method method = target.getClass().getDeclaredMethod(prefix + suffix); - if (method.getParameterCount() == 0) { - if (!method.canAccess(target)) { - method.setAccessible(true); - } - return method.invoke(target); - } - } catch (Exception ignored) { - // Ignore. - } - } - - return null; - } - - private Integer parseIndex(String value) { - if (value == null || value.isBlank()) { - return null; - } - for (int i = 0; i < value.length(); i++) { - if (!Character.isDigit(value.charAt(i))) { - return null; - } - } - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - return null; - } - } - - private String decapitalize(String value) { - if (value == null || value.isEmpty()) { - return value; - } - if (value.length() > 1 && Character.isUpperCase(value.charAt(0)) && Character.isUpperCase(value.charAt(1))) { - return value; - } - return Character.toLowerCase(value.charAt(0)) + value.substring(1); - } - - private boolean isTruthy(Object value) { - if (value == null) { - return false; - } - if (value instanceof Boolean bool) { - return bool; - } - if (value instanceof Number number) { - return number.doubleValue() != 0; - } - if (value instanceof CharSequence seq) { - String text = seq.toString().trim(); - return !text.isEmpty() && !"false".equalsIgnoreCase(text); - } - if (value instanceof Iterable iterable) { - return iterable.iterator().hasNext(); - } - if (value.getClass().isArray()) { - return Array.getLength(value) > 0; - } - return true; - } - - // Default filters - private String capitalize(String value) { - if (value == null || value.isEmpty()) return value; - return value.substring(0, 1).toUpperCase() + value.substring(1).toLowerCase(); - } - - private String formatNumber(String value) { - try { - double num = Double.parseDouble(value); - if (num == (long) num) { - return String.format("%,d", (long) num); - } - return String.format("%,.2f", num); - } catch (NumberFormatException e) { - return value; - } - } - - private String formatPercent(String value) { - try { - double num = Double.parseDouble(value); - return String.format("%.0f%%", num * 100); - } catch (NumberFormatException e) { - return value; + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to load HTML from resource: " + resourceFileName, e); } } /** - * Creates a new TemplateProcessor with common game-related variables. + * Check if the UI context has an element with the given name. * - * @param playerName The player's name - * @return A new TemplateProcessor with player variable set + * @param context UI context + * @param name Element name */ - public static TemplateProcessor forPlayer(String playerName) { - return new TemplateProcessor().setVariable("playerName", playerName); + @SuppressWarnings("unchecked") + private boolean hasElement(UIContext context, String name) { + return context.getById(name, UIElementBuilder.class).isPresent(); + } + + // ===== Interface ===== + + @FunctionalInterface + public interface ValueResolver { + Optional resolve(String name); } } diff --git a/src/main/java/au/ellie/hyui/html/ast/Evaluator.java b/src/main/java/au/ellie/hyui/html/ast/Evaluator.java new file mode 100644 index 0000000..82ecb87 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/Evaluator.java @@ -0,0 +1,342 @@ +package au.ellie.hyui.html.ast; + +import au.ellie.hyui.HyUIPlugin; +import au.ellie.hyui.html.ast.context.FilterRegistry; +import au.ellie.hyui.html.ast.context.VariableStack.VariableStackImpl; +import au.ellie.hyui.html.ast.item.Node; +import au.ellie.hyui.html.ast.item.Node.BlockNode.EachBlockNode; +import au.ellie.hyui.html.ast.item.Node.BlockNode.IfBlockNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.BinaryOpNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.DefaultNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.LiteralNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PipeNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PropertyAccessNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.TextNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.VariableNode; +import au.ellie.hyui.html.ast.utils.NumericUtils; +import au.ellie.hyui.html.ast.utils.ReflectionUtils; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class Evaluator { + private final FilterRegistry filterRegistry; + private final VariableStackImpl contextStack; + + public Evaluator(Map variables, FilterRegistry filterRegistry) { + this.contextStack = new VariableStackImpl(variables); + this.filterRegistry = filterRegistry; + } + + /** + * Evaluate a list of AST nodes and return the resulting string. + * + * @param nodes The list of AST nodes to evaluate. + * @return The resulting string after evaluation. + */ + public String evaluate(List nodes) { + StringBuilder result = new StringBuilder(); + + for (Node node : nodes) + result.append(evaluateNode(node)); + + return result.toString().replaceAll("\\n+$", ""); + } + + /** + * Evaluate a single AST node and return the resulting string. + * + * @param node The AST node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateNode(Node node) { + return switch (node) { + case TextNode text -> text.content(); + case ExpressionNode expr -> { + Object value = evaluateExpression(expr); + yield value == null ? "" : value.toString(); + } + case IfBlockNode ifBlock -> evaluateIfBlock(ifBlock); + case EachBlockNode eachBlock -> evaluateEachBlock(eachBlock); + + default -> throw new IllegalStateException("Unexpected value: " + node); + }; + } + + /** + * Evaluate an expression node and return the resulting value. + * + * @param node The expression node to evaluate. + * @return The resulting value after evaluation. + */ + private Object evaluateExpression(ExpressionNode node) { + return switch (node) { + case LiteralNode literal -> literal.value(); + case VariableNode var -> contextStack.getVariable(var.name()); + case PropertyAccessNode prop -> evaluatePropertyAccess(prop); + case BinaryOpNode binary -> evaluateBinaryOp(binary); + case PipeNode pipe -> evaluatePipe(pipe); + case DefaultNode def -> evaluateDefault(def); + }; + } + + /** + * Evaluate a property access on an object. + * + * @param node The property access node. + * @return The value of the accessed property, or null if not found. + */ + private Object evaluatePropertyAccess(PropertyAccessNode node) { + Object obj = evaluateExpression(node.object()); + if (obj == null) return null; + + String property = node.property(); + + // Access via Map + if (obj instanceof Map map) + return map.get(property); + + // Access via Reflection + try { + Class clazz = obj.getClass(); + + try { + Field field = clazz.getDeclaredField(property); + field.setAccessible(true); + + return field.get(obj); + } catch (NoSuchFieldException e) { + var propName = property.substring(0, 1).toUpperCase() + property.substring(1); + List methodNames = new ArrayList<>() {{ + add(property); + add("get" + propName); + add("is" + propName); + }}; + + // Open methods + for (String name : methodNames) { + var method = ReflectionUtils.getTrulyPublicMethod(clazz, name); + + if (method.isPresent()) + return method.get().invoke(obj); + } + } + } catch (Exception _) { + HyUIPlugin.getLog().logWarn("Error accessing property " + property + " on " + obj.getClass()); + } + + return null; + } + + /** + * Evaluate a `binary` operation between two expressions. + * + * @param node The binary operation node. + * @return The result of the binary operation. + */ + private Object evaluateBinaryOp(BinaryOpNode node) { + Object left = evaluateExpression(node.left()); + Object right = evaluateExpression(node.right()); + + return switch (node.operator()) { + case COMP_EQUALS -> evaluateEquals(left, right); + case COMP_NOT_EQUALS -> !evaluateEquals(left, right); + case COMP_LESS_THAN -> evaluateComparison(left, right) < 0; + case COMP_GREATER_THAN -> evaluateComparison(left, right) > 0; + case COMP_LESS_EQUALS -> evaluateComparison(left, right) <= 0; + case COMP_GREATER_EQUALS -> evaluateComparison(left, right) >= 0; + case COMP_AND -> toBoolean(left) && toBoolean(right); + case COMP_OR -> toBoolean(left) || toBoolean(right); + case COMP_IN -> evaluateIn(left, right); + default -> throw new RuntimeException("Unknown operator: " + node.operator()); + }; + } + + /** + * Evaluate `equality` between two values. + * + * @param left Left value of equation + * @param right Right value of equation + * @return True if equal, false otherwise + */ + private boolean evaluateEquals(Object left, Object right) { + if (left == null && right == null) return true; + if (left == null || right == null) return false; + + Number leftNum = NumericUtils.toNumber(left); + Number rightNum = NumericUtils.toNumber(right); + + if (leftNum != null && rightNum != null) + return NumericUtils.equals(leftNum, rightNum); + + return Objects.equals(left, right); + } + + /** + * Evaluate comparison between two values. + * + * @param left Left value of comparison + * @param right Right value of comparison + * @return Negative if left < right, 0 if left == right, positive if left > right + */ + private int evaluateComparison(Object left, Object right) { + if (left == null && right == null) return 0; + if (left == null) return -1; + if (right == null) return 1; + + Number leftNum = NumericUtils.toNumber(left); + Number rightNum = NumericUtils.toNumber(right); + + if (leftNum != null && rightNum != null) + return NumericUtils.compare(leftNum, rightNum); + + if (left instanceof Comparable && left.getClass().isInstance(right)) { + @SuppressWarnings("unchecked") + Comparable leftComp = (Comparable) left; + return leftComp.compareTo(right); + } + + throw new RuntimeException("Cannot compare " + left.getClass().getSimpleName() + + " and " + right.getClass().getSimpleName()); + } + + /** + * Evaluate a `pipe` expression (filter application). + * + * @param node The pipe node + * @return The result of the filter application + */ + private Object evaluatePipe(PipeNode node) { + Object value = evaluateExpression(node.expression()); + FilterRegistry.Filter filter = filterRegistry.get(node.filterName()); + + return filter.apply(value); + } + + /** + * Evaluate a `default` expression and return the first non-null, non-empty alternative. + * + * @param node The default node to evaluate. + * @return The first non-null, non-empty alternative value, or null if none found. + */ + private Object evaluateDefault(DefaultNode node) { + for (ExpressionNode alternative : node.alternatives()) { + Object value = evaluateExpression(alternative); + if (value != null && !value.toString().isEmpty()) + return value; + } + + return null; + } + + /** + * Evaluate an `if` / `else` block node and return the resulting string. + * + * @param node The `if` block node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateIfBlock(IfBlockNode node) { + Object conditionValue = evaluateExpression(node.condition()); + + StringBuilder result = new StringBuilder(); + if (toBoolean(conditionValue)) { + for (Node child : node.thenBody()) + result.append(evaluateNode(child)); + } else { + for (Node child : node.elseBody()) + result.append(evaluateNode(child)); + } + + return result.toString(); + } + + /** + * Evaluate an `each` block node and return the resulting string. + * + * @param node The `each` block node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateEachBlock(EachBlockNode node) { + Object collectionValue = evaluateExpression(node.collection()); + + if (collectionValue == null) + return ""; + + Iterable items = toIterable(collectionValue); + StringBuilder result = new StringBuilder(); + + for (Object item : items) { + Map context = new HashMap<>(); + context.put(node.itemName(), item); + + contextStack.pushScope(context); + try { + for (Node child : node.body()) + result.append(evaluateNode(child)); + } finally { + contextStack.popScope(); + } + } + + return result.toString(); + } + + // ===== Helpers ===== + + /** + * Convert an object to a boolean value. + * + * @param value The object to convert + * @return The boolean value + */ + private boolean toBoolean(Object value) { + return switch (value) { + case null -> false; + case Boolean b -> b; + case Number n -> n.doubleValue() != 0; + case String s -> !s.isEmpty(); + case Collection c -> !c.isEmpty(); + case Map m -> !m.isEmpty(); + default -> true; + }; + } + + /** + * Convert an object to an iterable or throw an exception. + * + * @param value The object to convert + * @return The iterable + */ + private Iterable toIterable(Object value) { + if (value instanceof Iterable iterable) + return iterable; + + if (value.getClass().isArray()) + return Arrays.asList((Object[]) value); + + throw new RuntimeException("Cannot iterate over " + value.getClass()); + } + + /** + * Evaluate if needle is in haystack. + * + * @param needle Object to search for + * @param haystack Object to search in + * @return True if needle is in haystack, false otherwise + */ + private boolean evaluateIn(Object needle, Object haystack) { + return switch (haystack) { + case Collection collection -> collection.contains(needle); + case Map map -> map.containsKey(needle); + case String str when needle != null -> str.contains(needle.toString()); + case null, default -> false; + }; + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/Lexer.java b/src/main/java/au/ellie/hyui/html/ast/Lexer.java new file mode 100644 index 0000000..867f3d2 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/Lexer.java @@ -0,0 +1,392 @@ +package au.ellie.hyui.html.ast; + +import au.ellie.hyui.html.ast.item.Token; + +import java.util.ArrayList; +import java.util.List; + +public class Lexer { + private final String input; + private int pos = 0; + + // May be used for debug + private int line = 1; + private int column = 1; + + public Lexer(String input) { + this.input = input; + } + + /** + * Tokenize the input string into a list of tokens + */ + public List tokenize() { + List tokens = new ArrayList<>(); + + while (pos < input.length()) { + if (peek("{{#")) { + trimWhitespaceForBlock(tokens); + + tokens.add(new Token(Token.Type.BLOCK_OPEN, "{{#", pos)); + advance(3); + tokenizeExpression(tokens); + + skipBlockLineEnd(); + } else if (peek("{{/")) { + trimWhitespaceForBlock(tokens); + + tokens.add(new Token(Token.Type.BLOCK_CLOSE, "{{/", pos)); + advance(3); + tokenizeExpression(tokens); + + skipBlockLineEnd(); + } else if (peek("{{")) { + tokens.add(new Token(Token.Type.EXPR_OPEN, "{{", pos)); + advance(2); + tokenizeExpression(tokens); + } else + tokenizeText(tokens); + } + + tokens.add(new Token(Token.Type.GLOBAL_EOF, "", pos)); + + return tokens; + } + + /** + * Tokenize an expression until the closing "}}" + * + * @param tokens The list to add tokens to + */ + private void tokenizeExpression(List tokens) { + skipWhitespace(); + + while (pos < input.length()) { + if (peek("}}")) + break; + + var current = current(); + + // String + if (current == '"') { + tokens.add(tokenizeString()); + } + + // Variable + else if (current == '$') { + tokens.add(tokenizeVariable()); + } + + // Numbers + else if (Character.isDigit(current) || + (current == '-' && + pos + 1 < input.length() && + Character.isDigit(input.charAt(pos + 1)) + ) + ) { + tokens.add(tokenizeNumber()); + } + + // Keyword / Operator + else if (peek("==")) { + tokens.add(new Token(Token.Type.COMP_EQUALS, "==", pos)); + advance(2); + } else if (peek("!=")) { + tokens.add(new Token(Token.Type.COMP_NOT_EQUALS, "!=", pos)); + advance(2); + } else if (peek("<=")) { + tokens.add(new Token(Token.Type.COMP_LESS_EQUALS, "<=", pos)); + advance(2); + } else if (peek(">=")) { + tokens.add(new Token(Token.Type.COMP_GREATER_EQUALS, ">=", pos)); + advance(2); + } else if (peek("<")) { + tokens.add(new Token(Token.Type.COMP_LESS_THAN, "<", pos)); + advance(1); + } else if (peek(">")) { + tokens.add(new Token(Token.Type.COMP_GREATER_THAN, ">", pos)); + advance(1); + } else if (peek("&&")) { + tokens.add(new Token(Token.Type.COMP_AND, "&&", pos)); + advance(2); + } else if (peek("??")) { + tokens.add(new Token(Token.Type.EXPR_NULL_COALESCING, "??", pos)); + advance(2); + } else if (peek("||")) { + tokens.add(new Token(Token.Type.COMP_OR, "||", pos)); + advance(2); + } else if (peek("|")) { + tokens.add(new Token(Token.Type.EXPR_PIPE, "|", pos)); + advance(1); + } else if (peek(".")) { + tokens.add(new Token(Token.Type.EXPR_VARIABLE_DOT, ".", pos)); + advance(1); + } + + // Identifiers + else if (Character.isLetter(current)) + tokens.add(tokenizeIdentifier()); + + else + throwError("Unexpected character: " + current(), pos); + + skipWhitespace(); + } + + if (peek("}}")) { + tokens.add(new Token(Token.Type.EXPR_CLOSE, "}}", pos)); + advance(2); + } + } + + /** + * Tokenize a string literal + */ + private Token tokenizeString() { + int start = pos; + advance(); // Skip opening " + + StringBuilder sb = new StringBuilder(); + while (pos < input.length() && current() != '"') { + if (current() == '\\' && pos + 1 < input.length()) { + advance(); + char escaped = current(); + switch (escaped) { + case 'n' -> sb.append('\n'); + case 't' -> sb.append('\t'); + case '"' -> sb.append('"'); + case '\\' -> sb.append('\\'); + default -> sb.append(escaped); + } + advance(); + } else { + sb.append(current()); + advance(); + } + } + + if (current() != '"') + throwError("Unterminated string", start); + + advance(); // Skip closing " + + return new Token(Token.Type.EXPR_STRING, sb.toString(), start); + } + + /** + * Tokenize a variable (starts with $) + */ + private Token tokenizeVariable() { + int start = pos; + advance(); // Skip $ + StringBuilder sb = new StringBuilder(); + + while (pos < input.length() && (Character.isLetterOrDigit(current()) || current() == '_' || current() == '-')) { + sb.append(current()); + advance(); + } + + return new Token(Token.Type.EXPR_VARIABLE, sb.toString(), start); + } + + /** + * Tokenize a number (integer or decimal) + */ + private Token tokenizeNumber() { + StringBuilder sb = new StringBuilder(); + if (current() == '-') { + sb.append(current()); + advance(); + } + + // Must have at least one digit after the sign + int start = pos; + if (!Character.isDigit(current())) { + pos = start; + + throwError("Expected digit after '-'", pos); + } + + boolean hasDecimal = false; + while (pos < input.length() && (Character.isDigit(current()) || current() == '.')) { + if (current() == '.') { + if (hasDecimal) + break; + + hasDecimal = true; + } + + sb.append(current()); + advance(); + } + + return new Token(Token.Type.EXPR_NUMBER, sb.toString(), start); + } + + /** + * Tokenize an identifier or keyword + */ + private Token tokenizeIdentifier() { + int start = pos; + + StringBuilder sb = new StringBuilder(); + while (pos < input.length() && (Character.isLetterOrDigit(current()) || current() == '_' || current() == '-')) { + sb.append(current()); + advance(); + } + + String value = sb.toString(); + Token.Type type = switch (value) { + case "if" -> Token.Type.BLOCK_IF; + case "else" -> Token.Type.BLOCK_ELSE; + case "each" -> Token.Type.BLOCK_EACH; + case "true", "false" -> Token.Type.EXPR_BOOLEAN; + case "in" -> Token.Type.COMP_IN; + default -> Token.Type.EXPR_IDENTIFIER; + }; + + return new Token(type, value, start); + } + + /** + * Tokenize plain text until the next "{{" + * + * @param tokens The list to add tokens to + */ + private void tokenizeText(List tokens) { + int start = pos; + + StringBuilder sb = new StringBuilder(); + while (pos < input.length() && !peek("{{")) { + sb.append(current()); + advance(); + } + + if (!sb.isEmpty()) + tokens.add(new Token(Token.Type.GLOBAL_TEXT, sb.toString(), start)); + } + + // ===== Helpers ===== + + /** + * Returns the current character or '\0' if at the end of input + */ + private char current() { + return pos < input.length() ? input.charAt(pos) : '\0'; + } + + /** + * Peeks ahead to see if the next characters match the given string + * + * @param str The string to match + */ + private boolean peek(String str) { + return input.startsWith(str, pos); + } + + /** + * Advance the current position by one character + */ + private void advance() { + advance(1); + } + + /** + * Advance the current position by count characters + * + * @param count Number of characters to advance + */ + private void advance(int count) { + for (int i = 0; i < count && pos < input.length(); i++) { + if (input.charAt(pos) == '\n') { + line++; + column = 1; + } else + column++; + + pos++; + } + } + + /** + * Skip whitespace characters + */ + private void skipWhitespace() { + while (pos < input.length() && Character.isWhitespace(current())) + advance(); + } + + /** + * Trim trailing whitespace from the last text token in a block + * if it only contains whitespace after the last newline + * + * @param tokens The list of tokens to trim + */ + private void trimWhitespaceForBlock(List tokens) { + if (tokens.isEmpty()) + return; + + Token last = tokens.getLast(); + if (last.type() != Token.Type.GLOBAL_TEXT) + return; + + String text = last.value(); + int lastNewlineIndex = text.lastIndexOf('\n'); + + if (lastNewlineIndex == -1) { + if (tokens.size() == 1 && text.matches("^[ \\t]+$")) + tokens.removeFirst(); + + return; + } + + String afterLastNewline = text.substring(lastNewlineIndex + 1); + if (afterLastNewline.matches("^[ \\t]*$")) { + String keepPart = text.substring(0, lastNewlineIndex + 1); + tokens.set(tokens.size() - 1, new Token(Token.Type.GLOBAL_TEXT, keepPart, last.position())); + } + } + + /** + * Skip whitespace and a newline if present after a standalone tag + */ + private void skipBlockLineEnd() { + int start = pos; + + // Skip spaces and tabs + while (pos < input.length() && (current() == ' ' || current() == '\t')) + advance(); + + // Check for newline + if (pos < input.length() && current() == '\n') + advance(); + else if (pos < input.length() && current() == '\r') { + advance(); + if (pos < input.length() && current() == '\n') + advance(); + } else + pos = start; + } + + private String getLine(int lineNumber) { + String[] lines = input.split("\\R", -1); // handles \n, \r\n, etc. + if (lineNumber < 1 || lineNumber > lines.length) + return ""; + + return lines[lineNumber - 1]; + } + + private void throwError(String message, int errorPos) { + var arrow = " ".repeat(Math.max(0, errorPos)) + + "↳ " + message; + + String formattedMessage = String.format(""" + An error occurred when parsing the input at line %d, column %d + %s + %s + """, line, errorPos, getLine(line), arrow + ); + + throw new RuntimeException(formattedMessage); + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/Parser.java b/src/main/java/au/ellie/hyui/html/ast/Parser.java new file mode 100644 index 0000000..9985eaf --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/Parser.java @@ -0,0 +1,366 @@ +package au.ellie.hyui.html.ast; + +import au.ellie.hyui.html.ast.item.Node; +import au.ellie.hyui.html.ast.item.Node.BlockNode.EachBlockNode; +import au.ellie.hyui.html.ast.item.Node.BlockNode.IfBlockNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.BinaryOpNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.DefaultNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.LiteralNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PipeNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PropertyAccessNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.TextNode; +import au.ellie.hyui.html.ast.item.Node.ExpressionNode.VariableNode; +import au.ellie.hyui.html.ast.item.Token; + +import java.util.ArrayList; +import java.util.List; + +import static au.ellie.hyui.html.ast.item.Token.Type.*; + +public class Parser { + private final List tokens; + private int pos = 0; + + public Parser(List tokens) { + this.tokens = tokens; + } + + /** + * Parse the list of tokens into an AST + * + * @return List of AST nodes + */ + public List parse() { + List nodes = new ArrayList<>(); + + while (!isAtEnd()) + nodes.add(parseNode()); + + return nodes; + } + + /** + * Parse a single AST node + * + * @return AST node + */ + private Node parseNode() { + Token token = current(); + + return switch (token.type()) { + case GLOBAL_TEXT -> { + advance(); + yield new TextNode(token.value()); + } + case EXPR_OPEN -> parseExpression(); + case BLOCK_OPEN -> parseBlock(); + default -> throw new RuntimeException("Unexpected token: " + token); + }; + } + + /** + * Parse an expression + * + * @return AST node representing the expression + */ + private Node parseExpression() { + expect(EXPR_OPEN); + ExpressionNode expr = parseExpressionContent(); + expect(EXPR_CLOSE); + return expr; + } + + /** + * Parse the content of an expression + * + * @return Expression node + */ + private ExpressionNode parseExpressionContent() { + return parseDefault(); + } + + /** + * Parse `nullish` coalescing expressions + * + * @return Expression node + */ + private ExpressionNode parseDefault() { + List alternatives = new ArrayList<>(); + + do { + alternatives.add(parseOr()); + } while (match(EXPR_NULL_COALESCING)); + + return alternatives.size() == 1 ? alternatives.getFirst() : new DefaultNode(alternatives); + } + + /** + * Parse logical `OR` expressions + * + * @return Expression node + */ + private ExpressionNode parseOr() { + ExpressionNode left = parseAnd(); + + while (match(COMP_OR)) { + Token operator = previous(); + ExpressionNode right = parseAnd(); + left = new BinaryOpNode(left, operator.type(), right); + } + + return left; + } + + /** + * Parse logical `AND` expressions + * + * @return Expression node + */ + private ExpressionNode parseAnd() { + ExpressionNode left = parseComparison(); + + while (match(COMP_AND)) { + Token operator = previous(); + ExpressionNode right = parseComparison(); + left = new BinaryOpNode(left, operator.type(), right); + } + + return left; + } + + /** + * Parse `comparison` expressions + * + * @return Expression node + */ + private ExpressionNode parseComparison() { + ExpressionNode left = parsePipe(); + + if (match(COMP_EQUALS, COMP_NOT_EQUALS, COMP_LESS_THAN, + COMP_GREATER_THAN, COMP_LESS_EQUALS, COMP_GREATER_EQUALS, + COMP_IN)) { + Token operator = previous(); + ExpressionNode right = parsePipe(); + return new BinaryOpNode(left, operator.type(), right); + } + + return left; + } + + /** + * Parse `pipe` expressions + * + * @return Expression node + */ + private ExpressionNode parsePipe() { + ExpressionNode expr = parsePrimary(); + + while (match(EXPR_PIPE)) { + String filterName = expect(EXPR_IDENTIFIER).value(); + expr = new PipeNode(expr, filterName); + } + + return expr; + } + + /** + * Parse primary expressions (literals, variables, property access) + * + * @return Expression node + */ + private ExpressionNode parsePrimary() { + // String literal + if (match(EXPR_STRING)) + return new LiteralNode(previous().value()); + + // Number literal + if (match(EXPR_NUMBER)) { + String value = previous().value(); + + if (value.contains(".")) + return new LiteralNode(Double.parseDouble(value)); + else + return new LiteralNode(Long.parseLong(value)); + } + + // Boolean literal + if (match(EXPR_BOOLEAN)) + return new LiteralNode(Boolean.parseBoolean(previous().value())); + + // Variable with property access + if (match(EXPR_VARIABLE)) { + String varName = previous().value(); + ExpressionNode expr = new VariableNode(varName); + + while (match(EXPR_VARIABLE_DOT)) { + String property = expect(EXPR_IDENTIFIER).value(); + expr = new PropertyAccessNode(expr, property); + } + + return expr; + } + + throw new RuntimeException("Unexpected token in expression: " + current()); + } + + /** + * Parse a block (if, each, etc.) + * + * @return AST node representing the block + */ + private Node parseBlock() { + expect(BLOCK_OPEN); + + if (match(BLOCK_IF)) + return parseIfBlock(); + else if (match(BLOCK_EACH)) + return parseEachBlock(); + + throw new RuntimeException("Unknown block type: " + current()); + } + + /** + * Parse an `if` block + * + * @return IfBlockNode + */ + private IfBlockNode parseIfBlock() { + ExpressionNode condition = parseExpressionContent(); + expect(EXPR_CLOSE); + + List thenBody = new ArrayList<>(); + while (!check(BLOCK_CLOSE) && !(check(BLOCK_OPEN, BLOCK_ELSE))) + thenBody.add(parseNode()); + + List elseBody = new ArrayList<>(); + if (check(BLOCK_OPEN)) { + int savedPos = pos; + advance(); // Skip BLOCK_OPEN + + if (check(BLOCK_ELSE)) { + advance(); // Skip EXPR_ELSE + expect(EXPR_CLOSE); + + while (!check(BLOCK_CLOSE)) + elseBody.add(parseNode()); + } else + pos = savedPos; + } + + expect(BLOCK_CLOSE, BLOCK_IF, EXPR_CLOSE); + + return new IfBlockNode(condition, thenBody, elseBody); + } + + /** + * Parse an `each` block + * + * @return EachBlockNode + */ + private EachBlockNode parseEachBlock() { + // Syntaxe : {{#each $collection}} or {{#each $collection customName}} + // Optional name, default to "item" + + ExpressionNode collection = parseExpressionContent(); + + String itemName = "item"; + if (check(EXPR_IDENTIFIER)) + itemName = expect(EXPR_IDENTIFIER).value(); + + expect(EXPR_CLOSE); + + List body = new ArrayList<>(); + while (!check(BLOCK_CLOSE)) + body.add(parseNode()); + + expect(BLOCK_CLOSE, BLOCK_EACH, EXPR_CLOSE); + + return new EachBlockNode(itemName, collection, body); + } + + // ===== Helpers ===== + + /** + * Get the current token + */ + private Token current() { + return tokens.get(pos); + } + + /** + * Get the previous token + */ + private Token previous() { + return tokens.get(pos - 1); + } + + /** + * Get the next token + */ + private Token next() { + return tokens.get(pos + 1); + } + + /** + * Consume the current token and return it + */ + private Token advance() { + if (!isAtEnd()) pos++; + return previous(); + } + + /** + * If the current token matches any of the given types, consume it and return true. + * Otherwise, return false. + */ + private boolean match(Token.Type... types) { + for (Token.Type type : types) { + if (check(type)) { + advance(); + return true; + } + } + + return false; + } + + /** + * Check if token matches the given types, without consuming them. + * Starts from the current token and checks each type in order. + */ + private boolean check(Token.Type... types) { + var index = pos; + for (Token.Type type : types) { + if (tokens.get(index++).type() != type) + return false; + } + + return true; + } + + /** + * Except the tokens to match the given types, consuming them. + * Starts from the current token and checks each type in order. + * + * @throws RuntimeException if any of the expected types do not match + */ + private Token expect(Token.Type... types) { + Token token = current(); + for (Token.Type type : types) { + if (check(type)) + token = advance(); + else + throw new RuntimeException("Expected " + type + " but got " + current().type() + " at position " + current().position()); + } + + return token; + } + + /** + * Check if we have reached the end of the token list + */ + private boolean isAtEnd() { + return current().type() == GLOBAL_EOF; + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java b/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java new file mode 100644 index 0000000..1c388ae --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java @@ -0,0 +1,87 @@ +package au.ellie.hyui.html.ast.context; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public class FilterRegistry { + + private final Map filters = new HashMap<>(); + + public FilterRegistry() { + register("uppercase", value -> value == null ? null : value.toString().toUpperCase(), "upper"); + register("lowercase", value -> value == null ? null : value.toString().toLowerCase(), "lower"); + register("capitalize", value -> { + if (value == null) + return null; + + String str = value.toString(); + if (str.isEmpty()) + return str; + + return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase(); + }); + register("trim", value -> value == null ? null : value.toString().trim()); + register("length", value -> switch (value) { + case null -> 0; + case String s -> s.length(); + case Collection c -> c.size(); + case Map m -> m.size(); + default -> value.toString().length(); + }); + register("number", value -> { + try { + double num = Double.parseDouble(value.toString()); + if (num == (long) num) + return String.format(Locale.ENGLISH, "%,d", (long) num); + + return String.format("%,.2f", num); + } catch (NumberFormatException e) { + return value; + } + }); + register("percent", value -> { + try { + double num = Double.parseDouble(value.toString()); + + return String.format(Locale.ENGLISH, "%.0f%%", num * 100); + } catch (NumberFormatException e) { + return value; + } + }); + } + + /** + * Register a new filter. + * + * @param name The name of the filter. + * @param filter The filter implementation. + */ + public void register(String name, Filter filter, String... aliases) { + filters.put(name, filter); + + for (String alias : aliases) + filters.put(alias, filter); + } + + /** + * Get a filter by name. + * + * @param name The name of the filter. + * @return The filter implementation. + * @throws RuntimeException If the filter is not found. + */ + public Filter get(String name) { + Filter filter = filters.get(name); + if (filter == null) + throw new RuntimeException("Unknown filter: " + name); + + return filter; + } + + @FunctionalInterface + public interface Filter { + Object apply(Object value); + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java new file mode 100644 index 0000000..8b9d5fa --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java @@ -0,0 +1,96 @@ +package au.ellie.hyui.html.ast.context; + +import java.util.ArrayDeque; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +public interface VariableStack { + + /** + * Retrieve a variable from the stack. + * + * @param name Variable name + * @return Variable value, or null if not found + */ + Object getVariable(String name); + + /** + * Retrieve a variable from the stack, with a default value. + * + * @param name Variable name + * @param defaultValue Default value if variable not found + * @return Variable value, or defaultValue if not found + */ + Object getVariable(String name, Object defaultValue); + + // ========== INTERNAL RECORDS ========== + + sealed interface VariableValue permits Value, Lazy, Computed { + } + + record Value(Object value) implements VariableValue { + } + + record Lazy(Supplier supplier) implements VariableValue { + } + + record Computed(Function function) implements VariableValue { + } + + // ========== IMPLEMENTATION ========== + + class VariableStackImpl implements VariableStack { + private final ArrayDeque> stack = new ArrayDeque<>(); + + public VariableStackImpl(Map globalScope) { + pushScope(globalScope); + } + + public void pushScope(Map scope) { + stack.push(scope); + } + + public void popScope() { + if (stack.size() > 1) + stack.pop(); + else + throw new IllegalStateException("Cannot pop the global scope"); + } + + public Object getVariable(String name) { + return getVariable(name, null); + } + + public Object getVariable(String name, Object defaultValue) { + for (Map scope : stack) { + if (scope.containsKey(name)) { + var object = scope.get(name); + + switch (object) { + case Supplier supplier -> { + object = supplier.get(); + scope.put(name, object); + return object; + } + case Function function -> { + @SuppressWarnings("unchecked") + Function stackFunction = + (Function) function; + + return stackFunction.apply(this); + } + case null -> { + return null; + } + default -> { + return object; + } + } + } + } + + return defaultValue; + } + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/item/Node.java b/src/main/java/au/ellie/hyui/html/ast/item/Node.java new file mode 100644 index 0000000..e1e3c1f --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/item/Node.java @@ -0,0 +1,95 @@ +package au.ellie.hyui.html.ast.item; + +import java.util.List; +import java.util.Map; + +public interface Node { + + // ---- Expression Nodes ---- + + sealed interface ExpressionNode extends Node { + /** + * Represents plain text in the template + */ + record TextNode(String content) implements Node { + } + + /** + * Represents a literal value (string, number, boolean) + */ + record LiteralNode(Object value) implements ExpressionNode { + } + + /** + * Represents a variable reference + */ + record VariableNode(String name) implements ExpressionNode { + } + + /** + * Represents accessing a property of an object + */ + record PropertyAccessNode(ExpressionNode object, String property) implements ExpressionNode { + } + + /** + * Represents a binary operation between two expressions + */ + record BinaryOpNode(ExpressionNode left, Token.Type operator, ExpressionNode right) implements ExpressionNode { + } + + /** + * Represents applying a filter to an expression + */ + record PipeNode(ExpressionNode expression, String filterName) implements ExpressionNode { + } + + /** + * Represents a list of alternative expressions (like coalesce) + */ + record DefaultNode(List alternatives) implements ExpressionNode { + } + + } + + // ---- Control Flow Nodes ---- + + interface BlockNode extends Node { + /** + * Represents an if control structure + */ + record IfBlockNode(ExpressionNode condition, List thenBody, List elseBody) implements BlockNode { + } + + /** + * Represents an `each` control structure + */ + record EachBlockNode(String itemName, ExpressionNode collection, List body) implements BlockNode { + } + } + + // ---- Attribute Value Nodes ---- + + sealed interface AttributeValue { + record Static(String value) implements AttributeValue { + } + + record Dynamic(ExpressionNode expression) implements AttributeValue { + } + + record Flag() implements AttributeValue { + } + } + + // ---- HTML Nodes ---- + + record HtmlElementNode( + String tagName, + Map attributes, + Map customAttributes, + List children, + boolean selfClosing + ) implements Node { + } +} + diff --git a/src/main/java/au/ellie/hyui/html/ast/item/Token.java b/src/main/java/au/ellie/hyui/html/ast/item/Token.java new file mode 100644 index 0000000..bc48ffa --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/item/Token.java @@ -0,0 +1,48 @@ +package au.ellie.hyui.html.ast.item; + +public record Token(Type type, String value, int position) { + public enum Type { + // Expression + EXPR_OPEN, // {{ + EXPR_CLOSE, // }} + EXPR_VARIABLE, // $name + EXPR_VARIABLE_DOT, // . + EXPR_STRING, // "text" + EXPR_NUMBER, // 123, 45.6 + EXPR_BOOLEAN, // true, false + EXPR_PIPE, // | + EXPR_NULL_COALESCING, // ?? (DEFAULT) + EXPR_IDENTIFIER, // Function name, properties + + // Block + BLOCK_OPEN, // {{# + BLOCK_CLOSE, // {{/ + BLOCK_IF, // if + BLOCK_EACH, // each + BLOCK_ELSE, // else + + // Html + TAG_OPEN, // < + TAG_CLOSE, // > + TAG_SELF_CLOSE, // /> + TAG_END_OPEN, // + COMP_LESS_EQUALS, // <= + COMP_GREATER_EQUALS, // >= + COMP_IN, // in + COMP_AND, // && + COMP_OR, // || + + // Special + GLOBAL_ASSIGN, // = + GLOBAL_TEXT, // Text / Html + GLOBAL_EOF + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java b/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java new file mode 100644 index 0000000..b15c8d8 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java @@ -0,0 +1,107 @@ +package au.ellie.hyui.html.ast.utils; + +import javax.annotation.Nullable; + +public class NumericUtils { + private static final double EPSILON = 1e-9; + + /** + * Convertit une valeur en Number si possible + */ + public static Number toNumber(@Nullable Object value) { + switch (value) { + case Number num -> { + return num; + } + + case String str -> { + try { + if (str.contains(".")) + return Double.parseDouble(str); + else + return Long.parseLong(str); + } catch (NumberFormatException ignored) { + return null; + } + } + + case null, default -> { + return null; + } + } + } + + /** + * Convertit un Number en double + */ + public static double toDouble(Number num) { + if (num == null) + return 0.0; + + return num.doubleValue(); + } + + /** + * Convertit un Number en long + */ + public static long toLong(Number num) { + if (num == null) + return 0L; + + return num.longValue(); + } + + /** + * Compare deux nombres avec epsilon pour les doubles + */ + public static int compare(Object left, Object right) { + Number leftNum = toNumber(left); + Number rightNum = toNumber(right); + + if (leftNum == null && rightNum == null) return 0; + if (leftNum == null) return -1; + if (rightNum == null) return 1; + + // Si au moins un des deux est un double/float, on compare en double avec epsilon + if (isFloatingPoint(leftNum) || isFloatingPoint(rightNum)) { + return compareWithEpsilon(toDouble(leftNum), toDouble(rightNum)); + } + + // Sinon, comparaison en long + return Long.compare(toLong(leftNum), toLong(rightNum)); + } + + /** + * Vérifie l'égalité entre deux nombres avec epsilon pour les doubles + */ + public static boolean equals(Object left, Object right) { + Number leftNum = toNumber(left); + Number rightNum = toNumber(right); + + if (leftNum == null && rightNum == null) return true; + if (leftNum == null || rightNum == null) return false; + + // Si au moins un des deux est un double/float, on compare avec epsilon + if (isFloatingPoint(leftNum) || isFloatingPoint(rightNum)) { + return Math.abs(toDouble(leftNum) - toDouble(rightNum)) < EPSILON; + } + + // Sinon, comparaison exacte en long + return toLong(leftNum) == toLong(rightNum); + } + + /** + * Vérifie si un Number est un type à virgule flottante + */ + private static boolean isFloatingPoint(Number num) { + return num instanceof Double || num instanceof Float; + } + + /** + * Compare deux doubles avec epsilon + */ + private static int compareWithEpsilon(double a, double b) { + if (Math.abs(a - b) < EPSILON) return 0; + return Double.compare(a, b); + } +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java b/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java new file mode 100644 index 0000000..5f94548 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java @@ -0,0 +1,72 @@ +package au.ellie.hyui.html.ast.utils; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class ReflectionUtils { + + public static Optional getTrulyPublicMethod(Class clazz, String name, Class... paramTypes) { + return getTrulyPublicMethods(clazz) + .stream() + .filter(method -> matches(method, name, paramTypes)) + .reduce((m1, m2) -> { + Class r1 = m1.getReturnType(); + Class r2 = m2.getReturnType(); + + return r1 != r2 && r1.isAssignableFrom(r2) ? m2 : m1; + }); + } + + public static Collection getTrulyPublicMethods(Class clazz) { + Map result = new HashMap<>(); + findTrulyPublicMethods(clazz, result); + + return List.copyOf(result.values()); + } + + private static void findTrulyPublicMethods(Class clazz, Map result) { + if (clazz == null) + return; + + Method[] methods = clazz.getMethods(); + for (Method method : methods) { + if (isTrulyPublic(method)) + result.putIfAbsent(toString(method), method); + } + + for (Class intf : clazz.getInterfaces()) + findTrulyPublicMethods(intf, result); + + findTrulyPublicMethods(clazz.getSuperclass(), result); + } + + private static boolean isTrulyPublic(Method method) { + return Modifier.isPublic(method.getModifiers() + & method.getDeclaringClass().getModifiers()); + } + + private static String toString(Method method) { + String prefix = method.getReturnType().getCanonicalName() + + method.getName() + " ("; + + return Stream.of(method.getParameterTypes()) + .map(Class::getCanonicalName) + .collect(Collectors.joining(", ", + prefix, ")")); + } + + private static boolean matches( + Method method, String name, Class... paramTypes) { + + return method.getName().equals(name) + && Arrays.equals(method.getParameterTypes(), paramTypes); + } +} diff --git a/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java b/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java index 0ebc006..7feaae8 100644 --- a/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java +++ b/src/main/java/au/ellie/hyui/utils/multiplehud/MultipleHUD.java @@ -3,8 +3,6 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud; -import com.hypixel.hytale.server.core.plugin.JavaPlugin; -import com.hypixel.hytale.server.core.plugin.JavaPluginInit; import com.hypixel.hytale.server.core.universe.PlayerRef; import javax.annotation.Nonnull; diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index 8d181db..767851c 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -1,345 +1,867 @@ package au.ellie.hyui.html; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; class TemplateProcessorTest { private TemplateProcessor processor; + // ========== UTILITY METHODS ========== + + /** + * Normalizes indentation by removing common leading whitespace from all lines. + * This allows templates in tests to be written with natural indentation. + */ + private static String normalize(String input) { + if (input == null || input.isBlank()) return ""; + + var lines = input.lines().toList(); + var minIndent = lines.stream() + .filter(line -> !line.isBlank()) + .mapToInt(line -> { + int i = 0; + while (i < line.length() && Character.isWhitespace(line.charAt(i))) i++; + return i; + }) + .min() + .orElse(0); + + return String.join("\n", lines.stream() + .map(line -> line.length() >= minIndent ? line.substring(minIndent) : line) + .dropWhile(String::isBlank) + .toList()) + .stripTrailing(); + } + @BeforeEach void setUp() { processor = new TemplateProcessor(); } - /* -------------------------------------------------- - * Variable substitution - * -------------------------------------------------- */ + // ========== TEST DATA RECORDS ========== + + record Address(String city, String country) { + } + + record Person(String name, int age, Address address) { + } + + record User(String name) { + } + + record Category(String name, List items) { + } - @Test - void replacesSimpleVariable() { - processor.setVariable("name", "Ellie"); + record Item(String name, boolean active, String display) { + } - assertEquals( - "Hello Ellie!", - processor.process("Hello {{$name}}!") - ); + record Box(int size) { } - @Test - void usesDefaultValueWhenVariableIsMissing() { - assertEquals( - "Score: 0", - processor.process("Score: {{$score|0}}") - ); + record Product(String name, List tags) { } - /* -------------------------------------------------- - * Filters - * -------------------------------------------------- */ + // ========== BASIC FUNCTIONALITY ========== @Nested - class StringFilters { + @DisplayName("Basic Template Processing") + class BasicProcessing { @Test - void upper_convertsToUppercase() { - processor.setVariable("value", "hello"); + @DisplayName("Should return plain text unchanged") + void plainText() { + String template = "
Hello World
"; + assertEquals("
Hello World
", processor.process(template)); + } - assertEquals( - "HELLO", - processor.process("{{$value|upper}}") - ); + @Test + @DisplayName("Should replace simple variables") + void simpleVariable() { + processor.setVariable("name", "John"); + assertEquals("Hello John!", processor.process("Hello {{$name}}!")); } @Test - void lower_convertsToLowercase() { - processor.setVariable("value", "HeLLo"); + @DisplayName("Should handle missing variables as empty strings") + void missingVariable() { + assertEquals("Hello !", processor.process("Hello {{$name}}!")); + } - assertEquals( - "hello", - processor.process("{{$value|lower}}") - ); + @Test + @DisplayName("Should support variables with hyphens and underscores") + void variableNaming() { + processor.setVariable("my-var", "value1"); + processor.setVariable("my_var", "value2"); + assertEquals("value1 value2", processor.process("{{$my-var}} {{$my_var}}")); } @Test - void trim_removesLeadingAndTrailingWhitespace() { - processor.setVariable("value", " hello "); + @DisplayName("Should preserve intentional whitespace in HTML") + void whitespacePreservation() { + assertEquals("
Hello
", processor.process("
Hello
")); + } + } - assertEquals( - "hello", - processor.process("{{$value|trim}}") - ); + // ========== LITERALS ========== + + @Nested + @DisplayName("Literal Values") + class Literals { + + @Test + @DisplayName("Should handle string literals with escaping") + void stringLiterals() { + assertEquals("Hello World", processor.process("{{\"Hello World\"}}")); + assertEquals("Hello \"World\"", processor.process("{{\"Hello \\\"World\\\"\"}}")); + } + + @ParameterizedTest + @CsvSource({"{{42}}", "{{3.14}}", "{{-5}}"}) + @DisplayName("Should handle numeric literals") + void numericLiterals(String template) { + assertNotNull(processor.process(template)); + assertFalse(processor.process(template).isBlank()); } @Test - void capitalize_capitalizesFirstLetterOnly() { - processor.setVariable("value", "hello world"); + @DisplayName("Should handle boolean literals") + void booleanLiterals() { + assertEquals("true false", processor.process("{{true}} {{false}}")); + } + } - assertEquals( - "Hello world", - processor.process("{{$value|capitalize}}") - ); + // ========== PROPERTY ACCESS ========== + + @Nested + @DisplayName("Property Access") + class PropertyAccess { + + @Test + @DisplayName("Should access record properties") + void recordProperties() { + processor.setVariable("user", new Person("Alice", 30, null)); + assertEquals("Alice is 30", processor.process("{{$user.name}} is {{$user.age}}")); + } + + @Test + @DisplayName("Should access map properties") + void mapProperties() { + processor.setVariable("user", Map.of("name", "Bob", "age", 25)); + assertEquals("Bob is 25", processor.process("{{$user.name}} is {{$user.age}}")); + } + + @Test + @DisplayName("Should access nested properties") + void nestedProperties() { + processor.setVariable("user", new Person("Charlie", 21, new Address("Paris", "France"))); + assertEquals("Paris, France", processor.process("{{$user.address.city}}, {{$user.address.country}}")); + } + + @Test + @DisplayName("Should return empty string for missing properties") + void missingProperties() { + processor.setVariable("user", new Person("Dave", 32, null)); + assertEquals("", processor.process("{{$user.id}}")); + } + + @ParameterizedTest + @CsvSource({ + "false, 0, ", + "true, 1, value_1 - value_1" + }) + @DisplayName("Should handle supplier properties") + void supplierEvaluationOnIfCondition(boolean condition, int value, String expected) { + AtomicInteger evaluations = new AtomicInteger(); + + processor + .setVariable("enabled", condition) + .setVariable("secret", () -> { + evaluations.incrementAndGet(); + return "value_" + evaluations; + }); + + String template = """ + {{#if $enabled}} + {{$secret}} - {{$secret}} + {{/if}} + """; + + assertEquals(expected != null ? expected : "", processor.process(template).trim()); + assertEquals(value, evaluations.get()); + } + + @ParameterizedTest + @CsvSource({ + "false, 0, ", + "true, 2, value_1 - value_2" + }) + @DisplayName("Should handle function properties") + void functionEvaluationOnIfCondition(boolean condition, int value, String expected) { + AtomicInteger evaluations = new AtomicInteger(); + + processor + .setVariable("enabled", condition) + .setVariable("secret", (stack) -> { + evaluations.incrementAndGet(); + return "value_" + evaluations; + }); + + String template = """ + {{#if $enabled}} + {{$secret}} - {{$secret}} + {{/if}} + """; + + assertEquals(expected != null ? expected : "", processor.process(template).trim()); + assertEquals(value, evaluations.get()); } } + // ========== COMPARISON OPERATORS ========== + @Nested - class NumberFilters { + @DisplayName("Comparison Operators") + class ComparisonOperators { + + @ParameterizedTest + @CsvSource({ + "5, ==, 5, true", "5, ==, 3, false", + "5, !=, 3, true", "5, !=, 5, false", + "5, <, 10, true", "5, <, 3, false", + "5, >, 3, true", "5, >, 10, false", + "5, <=, 5, true", "5, <=, 3, false", + "5, >=, 5, true", "5, >=, 3, true" + }) + @DisplayName("Should evaluate comparison operators correctly") + void comparisonOperators(int left, String op, int right, boolean expected) { + processor.setVariable("a", left); + processor.setVariable("b", right); + assertEquals(String.valueOf(expected), processor.process("{{$a " + op + " $b}}")); + } + + @Test + @DisplayName("Should compare strings") + void stringComparison() { + processor.setVariable("name", "Alice"); + assertEquals("true", processor.process("{{$name == \"Alice\"}}")); + assertEquals("false", processor.process("{{$name == \"Bob\"}}")); + } + + @Test + @DisplayName("Should handle numeric type mixing (int, long, double)") + void numericTypeMixing() { + processor.setVariable("a", 5); + processor.setVariable("b", 5.0); + assertEquals("true", processor.process("{{$a == $b}}")); + assertEquals("false", processor.process("{{$a != $b}}")); + } + + @Test + @DisplayName("Should handle floating-point comparison with epsilon") + void floatingPointEpsilon() { + processor.setVariable("a", 0.1 + 0.2); + processor.setVariable("b", 0.3); + assertEquals("true", processor.process("{{$a == $b}}")); + } + + @Test + @DisplayName("Should handle null comparisons") + void nullComparison() { + assertEquals("true", processor.process("{{$value == $missing}}")); + } + } + + // ========== LOGICAL OPERATORS ========== + + @Nested + @DisplayName("Logical Operators") + class LogicalOperators { + + @Test + @DisplayName("Should evaluate AND operator") + void andOperator() { + processor.setVariable("a", true); + processor.setVariable("b", true); + processor.setVariable("c", false); + + assertEquals("true", processor.process("{{$a && $b}}")); + assertEquals("false", processor.process("{{$a && $c}}")); + assertEquals("false", processor.process("{{$c && $c}}")); + } @Test + @DisplayName("Should evaluate OR operator") + void orOperator() { + processor.setVariable("a", true); + processor.setVariable("b", false); + + assertEquals("true", processor.process("{{$a || $b}}")); + assertEquals("false", processor.process("{{$b || $b}}")); + } + + @Test + @DisplayName("Should combine AND and OR operators") + void combinedLogicalOperators() { + processor.setVariable("a", true); + processor.setVariable("b", false); + processor.setVariable("c", true); + + assertEquals("true", processor.process("{{$a && $b || $c}}")); + } + + @ParameterizedTest + @CsvSource({ + "'', false", + "Hello, true", + "0, false", + "5, true" + }) + @DisplayName("Should evaluate truthiness correctly") + void truthiness(String value, boolean isTruthy) { + if (value.isEmpty()) { + processor.setVariable("val", ""); + } else if (value.matches("\\d+")) { + processor.setVariable("val", Integer.parseInt(value)); + } else { + processor.setVariable("val", value); + } + + String expected = isTruthy ? "true" : ""; + assertEquals(expected, processor.process("{{#if $val}}true{{/if}}")); + } + } + + // ========== IN OPERATOR ========== + + @Nested + @DisplayName("IN Operator") + class InOperator { + + @Test + @DisplayName("Should check presence in list") + void listContains() { + processor.setVariable("items", List.of("apple", "banana", "cherry")); + assertEquals("true", processor.process("{{\"apple\" in $items}}")); + assertEquals("false", processor.process("{{\"orange\" in $items}}")); + } + + @Test + @DisplayName("Should check key presence in map") + void mapContainsKey() { + processor.setVariable("user", Map.of("name", "Alice", "age", 30)); + assertEquals("true", processor.process("{{\"name\" in $user}}")); + assertEquals("false", processor.process("{{\"email\" in $user}}")); + } + + @Test + @DisplayName("Should check substring in string") + void stringContains() { + processor.setVariable("text", "Hello World"); + assertEquals("true", processor.process("{{\"World\" in $text}}")); + assertEquals("false", processor.process("{{\"Java\" in $text}}")); + } + } + + // ========== FILTERS ========== + + @Nested + @DisplayName("Filters") + class Filters { + + @ParameterizedTest + @CsvSource({ + "john, uppercase, JOHN", + "JANE, lowercase, jane", + "alice, capitalize, Alice", + "' Hello ', trim, Hello" + }) + @DisplayName("Should apply built-in filters") + void builtInFilters(String input, String filter, String expected) { + processor.setVariable("value", input); + assertEquals(expected, processor.process("{{$value | " + filter + "}}")); + } + + @Test + @DisplayName("Should chain multiple filters") + void chainedFilters() { + processor.setVariable("name", " john doe "); + assertEquals("JOHN DOE", processor.process("{{$name | trim | uppercase}}")); + } + + @Test + @DisplayName("Should support custom filters") + void customFilter() { + processor.registerFilter("reverse", value -> + value == null ? null : new StringBuilder(value.toString()).reverse().toString() + ); + + processor.setVariable("text", "Hello"); + assertEquals("olleH", processor.process("{{$text | reverse}}")); + } + + @Test + @DisplayName("Should apply length filter to strings and collections") + void lengthFilter() { + processor.setVariable("text", "Hello"); + processor.setVariable("items", List.of("a", "b", "c")); + + assertEquals("5", processor.process("{{$text | length}}")); + assertEquals("3", processor.process("{{$items | length}}")); + } + + @Test + @DisplayName("Should format numbers with number filter") void number_formatsNumber() { processor.setVariable("value", 1234); assertEquals( - "1 234", - processor.process("{{$value|number}}") + "1,234", + processor.process("{{$value | number}}") ); } @Test + @DisplayName("Should format percentages with percent filter") void percent_formatsPercent() { processor.setVariable("value", 0.125); assertEquals( "13%", - processor.process("{{$value|percent}}") + processor.process("{{$value | percent}}") ); } } - /* -------------------------------------------------- - * If blocks - * -------------------------------------------------- */ + // ========== DEFAULT VALUES ========== + + @Nested + @DisplayName("Default Values (Nullish Coalescing)") + class DefaultValues { + + @Test + @DisplayName("Should use first non-null value") + void firstNonNull() { + processor.setVariable("name", "Alice"); + assertEquals("Alice", processor.process("{{$name ?? \"Guest\"}}")); + } + + @Test + @DisplayName("Should fallback to default when variable is null") + void fallbackToDefault() { + assertEquals("Guest", processor.process("{{$name ?? \"Guest\"}}")); + } + + @Test + @DisplayName("Should chain multiple defaults") + void chainedDefaults() { + processor.setVariable("b", "Value B"); + assertEquals("Value B", processor.process("{{$a ?? $b ?? \"Default\"}}")); + assertEquals("Default", processor.process("{{$a ?? $c ?? \"Default\"}}")); + } + + @Test + @DisplayName("Should combine defaults with filters") + void defaultsWithFilters() { + assertEquals("GUEST", processor.process("{{$name | uppercase ?? \"GUEST\"}}")); + + processor.setVariable("name", "john"); + assertEquals("JOHN", processor.process("{{$name | uppercase ?? \"GUEST\"}}")); + } + + @Test + @DisplayName("Should handle complex expressions with defaults, filters, and properties") + void complexDefaultExpression() { + record User(String firstName, String lastName) { + } + processor.setVariable("user", new User(null, "Doe")); + + assertEquals("DOE", processor.process("{{$user.firstName | uppercase ?? $user.lastName | uppercase ?? \"GUEST\"}}")); + } + } + + // ========== IF BLOCKS ========== @Nested + @DisplayName("Conditional Blocks (if)") class IfBlocks { @Test - void rendersTrueBranch() { - processor.setVariable("loggedIn", true); + @DisplayName("Should render content when condition is true") + void renderWhenTrue() { + processor.setVariable("show", true); + assertEquals("
Visible
", processor.process("{{#if $show}}
Visible
{{/if}}")); + } - String template = """ - {{#if loggedIn}} - Welcome back! - {{else}} - Please log in + @Test + @DisplayName("Should not render content when condition is false") + void notRenderWhenFalse() { + processor.setVariable("show", false); + assertEquals("", processor.process("{{#if $show}}
Hidden
{{/if}}")); + } + + @Test + @DisplayName("Should evaluate complex conditions") + void complexConditions() { + processor.setVariable("count", 5); + processor.setVariable("enabled", true); + + assertEquals("
Show
", processor.process("{{#if $enabled && $count > 3}}
Show
{{/if}}")); + } + + @Test + @DisplayName("Should support nested if blocks") + void nestedIf() { + processor.setVariable("outer", true); + processor.setVariable("inner", true); + + String template = normalize(""" + {{#if $outer}} + Outer + {{#if $inner}} + Inner {{/if}} - """; + {{/if}} + """); - assertEquals( - "Welcome back!", - processor.process(template).trim() - ); + String result = processor.process(template); + assertTrue(result.contains("Outer")); + assertTrue(result.contains("Inner")); + + processor.setVariable("inner", false); + result = processor.process(template); + assertTrue(result.contains("Outer")); + assertFalse(result.contains("Inner")); } @Test - void rendersFalseBranch() { - processor.setVariable("loggedIn", false); + @DisplayName("Should handle complex if with comparisons") + void complexIfComparison() { + processor.setVariable("score", 85); - String template = """ - {{#if loggedIn}} - Welcome! - {{else}} - Please log in + String template = normalize(""" + {{#if $score >= 90}} + A {{/if}} - """; + {{#if $score >= 80 && $score < 90}} + B + {{/if}} + {{#if $score < 80}} + C + {{/if}} + """); - assertEquals( - "Please log in", - processor.process(template).trim() - ); + String result = processor.process(template); + assertTrue(result.contains("B")); + assertFalse(result.contains("A")); + assertFalse(result.contains("C")); } - @Test - void withoutElse_rendersNothingWhenFalse() { - processor.setVariable("enabled", false); + @ParameterizedTest + @CsvSource({ + "true, true, Welcome back!", + "true, true, Welcome back!", + "false, true, Rendering is disabled", + }) + @DisplayName("Should render inner else branch when condition switch") + void rendersIfElseBranch(boolean render, boolean loggedIn, String expected) { + processor.setVariable("render", render); + processor.setVariable("loggedIn", loggedIn); String template = """ - Before - {{#if enabled}} - Enabled + {{#if $render}} + {{#if $loggedIn}} + Welcome back! + {{#else}} + Please log in + {{/if}} + {{#else}} + Rendering is disabled {{/if}} - After """; - String result = processor.process(template) - .replaceAll("\\s+", " ") - .trim(); - - assertEquals("Before After", result); + assertEquals(expected, processor.process(template).trim()); } } - /* -------------------------------------------------- - * Each blocks - * -------------------------------------------------- */ + // ========== EACH BLOCKS ========== @Nested + @DisplayName("Iteration Blocks (each)") class EachBlocks { @Test - void iteratesOverList() { + @DisplayName("Should iterate with default item name") + void iterateWithDefaultName() { processor.setVariable("items", List.of("A", "B", "C")); + assertEquals("A B C ", processor.process("{{#each $items}}{{$item}} {{/each}}")); + } - String template = """ - {{#each items}} - {{$item}} - {{/each}} - """; + @Test + @DisplayName("Should iterate with custom item name") + void iterateWithCustomName() { + processor.setVariable("items", List.of("A", "B", "C")); + assertEquals("A B C ", processor.process("{{#each $items element}}{{$element}} {{/each}}")); + } - String result = processor.process(template) - .replaceAll("\\s+", ""); + @Test + @DisplayName("Should iterate over records with property access") + void iterateRecords() { + record Item(String name, int value) { + } + processor.setVariable("items", List.of(new Item("First", 1), new Item("Second", 2))); + + assertEquals("First:1 Second:2 ", processor.process("{{#each $items}}{{$item.name}}:{{$item.value}} {{/each}}")); + assertEquals("First:1 Second:2 ", processor.process("{{#each $items product}}{{$product.name}}:{{$product.value}} {{/each}}")); + } - assertEquals( - "ABC", - result - ); + @Test + @DisplayName("Should handle empty collections") + void emptyCollection() { + processor.setVariable("items", List.of()); + assertEquals("", processor.process("{{#each $items}}{{$item}}{{/each}}")); } @Test - void exposesMapEntriesAsVariables() { - processor.setVariable( - "users", - List.of( - Map.of("name", "Alice"), - Map.of("name", "Bob") - ) - ); + @DisplayName("Should access global variables inside loops") + void globalVariablesInLoop() { + processor.setVariable("prefix", "Item"); + processor.setVariable("numbers", List.of(1, 2, 3)); - String template = """ - {{#each users}} - {{$name}} + assertEquals("Item 1 Item 2 Item 3 ", processor.process("{{#each $numbers}}{{$prefix}} {{$item}} {{/each}}")); + assertEquals("Number 1 Number 2 Number 3 ", processor.process("{{#each $numbers num}}Number {{$num}} {{/each}}")); + } + + @Test + @DisplayName("Should support nested loops with custom names to avoid conflicts") + void nestedLoopsWithCustomNames() { + processor.setVariable("categories", List.of( + new Category("Fruits", List.of("Apple", "Banana")), + new Category("Vegetables", List.of("Carrot", "Lettuce")) + )); + + String template = normalize(""" + {{#each $categories cat}} + {{$cat.name}}: + {{#each $cat.items product}} + - {{$product}} {{/each}} - """; + {{/each}} + """); + + assertEquals(normalize(""" + Fruits: + - Apple + - Banana + Vegetables: + - Carrot + - Lettuce + """), processor.process(template)); + } - assertEquals( - "AliceBob", - processor.process(template).replaceAll("\\s+", "") - ); + @Test + @DisplayName("Should handle null values in collections") + void nullValuesInCollection() { + List items = new ArrayList<>(); + items.add("A"); + items.add(null); + items.add("C"); + processor.setVariable("items", items); + + assertEquals("A,,C,", processor.process("{{#each $items}}{{$item}},{{/each}}")); } } - /* -------------------------------------------------- - * Components - * -------------------------------------------------- */ + // ========== COMBINED BLOCKS ========== @Nested - class Components { + @DisplayName("Combined Conditional and Iteration") + class CombinedBlocks { @Test - void expandsComponentWithParameters() { - processor.registerComponent( - "button", - "" - ); + @DisplayName("Should combine if and each blocks") + void ifInsideEach() { + processor.setVariable("items", List.of( + new Item("First", true, null), + new Item("Second", false, null), + new Item("Third", true, null) + )); + + String template = normalize(""" + {{#each $items}} + {{#if $item.active}} +
{{$item.name}}
+ {{/if}} + {{/each}} + """); - assertEquals( - "", - processor.process("{{@button:text=Click Me,id=myBtn}}") - ); + assertEquals(normalize(""" +
First
+
Third
+ """), processor.process(template)); } @Test - void componentCanAccessVariablesFromScope() { - processor - .setVariable("label", "Submit") - .registerComponent("button", ""); - - assertEquals( - "", - processor.process("{{@button}}") - ); + @DisplayName("Should handle complex real-world template") + void complexRealWorldTemplate() { + processor.setVariable("preset-active", "preset_01"); + processor.setVariable("render", true); + processor.setVariable("preset-list", List.of( + new Item("preset_01", true, "Test name"), + new Item("preset_02", true, "Test name 02") + )); + + String template = normalize(""" +
+
+ +
+ + {{#if $render && $preset-list.size > 1}} +
+ +
+ {{/if}} +
+ """); + + String result = processor.process(template); + assertEquals(normalize(""" +
+
+ +
+ +
+ +
+
+ """), result); } } - /* -------------------------------------------------- - * Supplier laziness - * -------------------------------------------------- */ + // ========== COMPONENTS ========== + +// @Nested +// @DisplayName("Components") +// class Components { +// +// @Test +// @DisplayName("Should expand simple component with parameters") +// void expandsComponentWithParameters() { +// processor.registerComponent( +// "button", +// "" +// ); +// +// assertEquals( +// "", +// processor.process("{{@button:text=Click Me,id=myBtn}}") +// ); +// } +// +// @Test +// @DisplayName("Should allow components to access variables from scope") +// void componentCanAccessVariablesFromScope() { +// processor +// .setVariable("label", "Submit") +// .registerComponent("button", ""); +// +// assertEquals( +// "", +// processor.process("{{@button}}") +// ); +// } +// } + + // ========== ERROR HANDLING ========== @Nested - class SupplierEvaluation { + @DisplayName("Error Handling") + class ErrorHandling { @Test - void supplierIsNotEvaluatedWhenIfConditionIsFalse() { - AtomicInteger evaluations = new AtomicInteger(); - - processor - .setVariable("enabled", false) - .setVariable("secret", () -> { - evaluations.incrementAndGet(); - return "SHOULD NOT HAPPEN"; - }); + @DisplayName("Should throw exception for unterminated string") + void unterminatedString() { + assertThrows(RuntimeException.class, () -> processor.process("{{\"unterminated}}")); + } - String template = """ - {{#if enabled}} - {{$secret}} - {{/if}} - """; + @Test + @DisplayName("Should throw exception for unknown filter") + void unknownFilter() { + processor.setVariable("name", "John"); + assertThrows(RuntimeException.class, () -> processor.process("{{$name | unknownfilter}}")); + } - assertEquals("", processor.process(template).trim()); - assertEquals(0, evaluations.get(), "Supplier must not be evaluated"); + @Test + @DisplayName("Should throw exception for unclosed if block") + void unclosedIfBlock() { + assertThrows(RuntimeException.class, () -> processor.process("{{#if $var}}Content")); } @Test - void supplierIsEvaluatedWhenIfConditionIsTrue() { - AtomicInteger evaluations = new AtomicInteger(); + @DisplayName("Should throw exception for unclosed each block") + void unclosedEachBlock() { + assertThrows(RuntimeException.class, () -> processor.process("{{#each $items}}Content")); + } + } - processor - .setVariable("enabled", true) - .setVariable("value", () -> { - evaluations.incrementAndGet(); - return "OK"; - }); + // ========== PERFORMANCE ========== - String template = """ - {{#if enabled}} - {{$value}} - {{/if}} - """; + @Nested + @DisplayName("Performance") + class Performance { - assertEquals("OK", processor.process(template).trim()); - assertEquals(1, evaluations.get()); + @Test + @DisplayName("Should handle large lists efficiently") + void largeListPerformance() { + List largeList = new ArrayList<>(); + for (int i = 0; i < 1000; i++) largeList.add(i); + processor.setVariable("numbers", largeList); + + long start = System.currentTimeMillis(); + String result = processor.process("{{#each $numbers}}{{$item}},{{/each}}"); + long duration = System.currentTimeMillis() - start; + + assertTrue(duration < 1000, "Processing 1000 items should complete in less than 1 second"); + assertTrue(result.contains("0,")); + assertTrue(result.contains("999,")); } - } - /* -------------------------------------------------- - * Combined scenario - * -------------------------------------------------- */ - - @Test - void complexTemplateRendersCorrectly() { - processor - .setVariable("player", "Ellie") - .setVariable("online", true) - .setVariable("scores", List.of(10, 20)) - .registerComponent("score", "
  • {{$item}}
  • "); - - String template = """ -

    Hello {{$player}}

    - - {{#if online}} -
      - {{#each scores}} - {{@score}} + @Test + @DisplayName("Should parse complex templates quickly") + void complexTemplatePerformance() { + String template = normalize(""" + {{#each $items}} + {{#if $item.active}} +
      {{$item.name | uppercase}}
      + {{/if}} {{/each}} -
    - {{else}} - Offline - {{/if}} - """; - - String result = processor.process(template) - .replaceAll("\\s+", ""); - - assertEquals( - "

    HelloEllie

    • 10
    • 20
    ", - result - ); + """); + + long start = System.currentTimeMillis(); + for (int i = 0; i < 100; i++) processor.process(template); + long duration = System.currentTimeMillis() - start; + + assertTrue(duration < 1000, "100 iterations should complete in less than 1 second"); + } } } diff --git a/src/test/java/au/ellie/hyui/html/ast/utils/NumericUtilsTest.java b/src/test/java/au/ellie/hyui/html/ast/utils/NumericUtilsTest.java new file mode 100644 index 0000000..530502e --- /dev/null +++ b/src/test/java/au/ellie/hyui/html/ast/utils/NumericUtilsTest.java @@ -0,0 +1,66 @@ +package au.ellie.hyui.html.ast.utils; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Numeric Utils Tests") +class NumericUtilsTest { + + @Test + @DisplayName("Convert to numbers") + void testToNumber() { + assertEquals(42L, NumericUtils.toNumber(42L)); + assertEquals(42.5, NumericUtils.toNumber(42.5)); + assertEquals(100L, NumericUtils.toNumber("100")); + assertEquals(100.5, NumericUtils.toNumber("100.5")); + + assertNull(NumericUtils.toNumber("not a number")); + assertNull(NumericUtils.toNumber(null)); + } + + @Test + @DisplayName("Compare with Epsilon") + void testCompareWithEpsilon() { + // Exact compare + assertEquals(0, NumericUtils.compare(5, 5)); + assertEquals(0, NumericUtils.compare(5.0, 5.0)); + + // Epsilon compare + assertEquals(0, NumericUtils.compare(0.1 + 0.2, 0.3)); + + // Difference compare + assertTrue(NumericUtils.compare(5, 10) < 0); + assertTrue(NumericUtils.compare(10, 5) > 0); + assertTrue(NumericUtils.compare(5.5, 10.5) < 0); + } + + @Test + @DisplayName("Ensure equality") + void testEquals() { + assertTrue(NumericUtils.equals(5, 5)); + assertTrue(NumericUtils.equals(5, 5.0)); + assertTrue(NumericUtils.equals(5.0, 5)); + assertTrue(NumericUtils.equals(0.1 + 0.2, 0.3)); + + assertFalse(NumericUtils.equals(5, 6)); + assertFalse(NumericUtils.equals(5.0, 6.0)); + + assertTrue(NumericUtils.equals(null, null)); + assertFalse(NumericUtils.equals(5, null)); + assertFalse(NumericUtils.equals(null, 5)); + } + + @Test + @DisplayName("Handle mixed types") + void testMixedTypes() { + assertTrue(NumericUtils.equals(42, 42L)); + assertTrue(NumericUtils.equals(42, 42.0)); + assertTrue(NumericUtils.equals(42L, 42.0)); + + assertEquals(0, NumericUtils.compare(42, 42L)); + assertEquals(0, NumericUtils.compare(42, 42.0)); + assertEquals(0, NumericUtils.compare(42L, 42.0)); + } +} \ No newline at end of file From 8a611c25f7424320a22a84e9b72d6f5d6f9e6789 Mon Sep 17 00:00:00 2001 From: Farrael Date: Mon, 2 Feb 2026 22:08:24 +0100 Subject: [PATCH 02/30] doc: add license header / refactor: reflection class update --- .../java/au/ellie/hyui/HyUIPluginLogger.java | 9 +- .../au/ellie/hyui/html/TemplateProcessor.java | 16 --- .../au/ellie/hyui/html/ast/Evaluator.java | 20 +++- .../java/au/ellie/hyui/html/ast/Lexer.java | 18 +++ .../java/au/ellie/hyui/html/ast/Parser.java | 18 +++ .../hyui/html/ast/context/FilterRegistry.java | 18 +++ .../hyui/html/ast/context/VariableStack.java | 18 +++ .../au/ellie/hyui/html/ast/item/Node.java | 18 +++ .../au/ellie/hyui/html/ast/item/Token.java | 18 +++ .../hyui/html/ast/utils/NumericUtils.java | 18 +++ .../hyui/html/ast/utils/ReflectionUtils.java | 108 ++++++++++++------ 11 files changed, 220 insertions(+), 59 deletions(-) diff --git a/src/main/java/au/ellie/hyui/HyUIPluginLogger.java b/src/main/java/au/ellie/hyui/HyUIPluginLogger.java index 83241ed..364d14a 100644 --- a/src/main/java/au/ellie/hyui/HyUIPluginLogger.java +++ b/src/main/java/au/ellie/hyui/HyUIPluginLogger.java @@ -21,19 +21,18 @@ import com.hypixel.hytale.logger.HytaleLogger; public class HyUIPluginLogger { - - private final HytaleLogger internalLogger = HytaleLogger.forEnclosingClass(); - + public static final boolean IS_DEV = "true".equals(System.getenv("HYUI_DEV")); + private final HytaleLogger internalLogger = HytaleLogger.forEnclosingClass(); public HyUIPluginLogger() { - + } public void logWarn(String message) { internalLogger.atWarning().log(message); } - + public void logFinest(String message) { internalLogger.atFinest().log(message); } diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index 6829adb..ea186dd 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -159,22 +159,6 @@ public TemplateProcessor registerComponent(String name, String template) { return this; } - /** - * Registers a reusable component template loaded from resources. - * - * @param name Component name (e.g., "button", "card") - * @param resourcePath Resource path to the component HTML - located in Common/UI/Custom/. - * @return This processor for chaining - */ - public TemplateProcessor registerComponentFromFile(String name, String resourcePath) { - if (resourcePath == null || resourcePath.isBlank()) { - throw new IllegalArgumentException("Resource path cannot be null or blank."); - } - String trimmed = resourcePath.startsWith("/") ? resourcePath.substring(1) : resourcePath; - String template = loadHtmlFromResources("/Common/UI/Custom/" + trimmed); - return registerComponent(name, template); - } - /** * Registers a reusable component template loaded from resources. * diff --git a/src/main/java/au/ellie/hyui/html/ast/Evaluator.java b/src/main/java/au/ellie/hyui/html/ast/Evaluator.java index 82ecb87..e2561b7 100644 --- a/src/main/java/au/ellie/hyui/html/ast/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/ast/Evaluator.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast; import au.ellie.hyui.HyUIPlugin; @@ -122,7 +140,7 @@ private Object evaluatePropertyAccess(PropertyAccessNode node) { // Open methods for (String name : methodNames) { - var method = ReflectionUtils.getTrulyPublicMethod(clazz, name); + var method = ReflectionUtils.getPublicMethod(clazz, name); if (method.isPresent()) return method.get().invoke(obj); diff --git a/src/main/java/au/ellie/hyui/html/ast/Lexer.java b/src/main/java/au/ellie/hyui/html/ast/Lexer.java index 867f3d2..d8af27e 100644 --- a/src/main/java/au/ellie/hyui/html/ast/Lexer.java +++ b/src/main/java/au/ellie/hyui/html/ast/Lexer.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast; import au.ellie.hyui.html.ast.item.Token; diff --git a/src/main/java/au/ellie/hyui/html/ast/Parser.java b/src/main/java/au/ellie/hyui/html/ast/Parser.java index 9985eaf..9f98738 100644 --- a/src/main/java/au/ellie/hyui/html/ast/Parser.java +++ b/src/main/java/au/ellie/hyui/html/ast/Parser.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast; import au.ellie.hyui.html.ast.item.Node; diff --git a/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java b/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java index 1c388ae..33c8f24 100644 --- a/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java +++ b/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast.context; import java.util.Collection; diff --git a/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java index 8b9d5fa..74ec4e5 100644 --- a/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java +++ b/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast.context; import java.util.ArrayDeque; diff --git a/src/main/java/au/ellie/hyui/html/ast/item/Node.java b/src/main/java/au/ellie/hyui/html/ast/item/Node.java index e1e3c1f..4aa716b 100644 --- a/src/main/java/au/ellie/hyui/html/ast/item/Node.java +++ b/src/main/java/au/ellie/hyui/html/ast/item/Node.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast.item; import java.util.List; diff --git a/src/main/java/au/ellie/hyui/html/ast/item/Token.java b/src/main/java/au/ellie/hyui/html/ast/item/Token.java index bc48ffa..c4889b6 100644 --- a/src/main/java/au/ellie/hyui/html/ast/item/Token.java +++ b/src/main/java/au/ellie/hyui/html/ast/item/Token.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast.item; public record Token(Type type, String value, int position) { diff --git a/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java b/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java index b15c8d8..1ff7efa 100644 --- a/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java +++ b/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast.utils; import javax.annotation.Nullable; diff --git a/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java b/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java index 5f94548..a5e7896 100644 --- a/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java +++ b/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java @@ -1,21 +1,53 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + package au.ellie.hyui.html.ast.utils; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; +/** + * Utility class for reflection-related operations. + * Based on the work of Dr Heinz M. Kabutz + */ public class ReflectionUtils { - public static Optional getTrulyPublicMethod(Class clazz, String name, Class... paramTypes) { - return getTrulyPublicMethods(clazz) - .stream() + /** + * Get a truly public method (i.e., a method that is public and declared in a public class or interface) + * from the given class or its superclasses/interfaces, avoiding calling method on packages with restricted access. + * + * @param clazz Class to inspect + * @param name Method name + * @param paramTypes Parameter types + * @return Optional containing the Method if found, or empty otherwise + */ + public static Optional getPublicMethod(Class clazz, String name, Class... paramTypes) { + if (clazz == null) + return Optional.empty(); + + List results = new ArrayList<>(); + findPublicMethods(clazz, results, name, paramTypes); + + return results.stream() .filter(method -> matches(method, name, paramTypes)) .reduce((m1, m2) -> { Class r1 = m1.getReturnType(); @@ -25,48 +57,50 @@ public static Optional getTrulyPublicMethod(Class clazz, String name, }); } - public static Collection getTrulyPublicMethods(Class clazz) { - Map result = new HashMap<>(); - findTrulyPublicMethods(clazz, result); - - return List.copyOf(result.values()); - } - - private static void findTrulyPublicMethods(Class clazz, Map result) { + /** + * Recursively find truly public methods in the class hierarchy. + * + * @param clazz Class to inspect + * @param results List to store found methods + * @param name Method name + * @param paramTypes Parameter types + */ + private static void findPublicMethods(Class clazz, List results, String name, Class... paramTypes) { if (clazz == null) return; Method[] methods = clazz.getMethods(); - for (Method method : methods) { - if (isTrulyPublic(method)) - result.putIfAbsent(toString(method), method); - } + for (Method method : methods) + if (matches(method, name, paramTypes) && isPublic(method)) + results.add(method); for (Class intf : clazz.getInterfaces()) - findTrulyPublicMethods(intf, result); + findPublicMethods(intf, results, name, paramTypes); - findTrulyPublicMethods(clazz.getSuperclass(), result); + findPublicMethods(clazz.getSuperclass(), results, name, paramTypes); } - private static boolean isTrulyPublic(Method method) { + /** + * Check if a method is truly public. + * + * @param method Method to check + * @return True if the method is truly public, false otherwise + */ + private static boolean isPublic(Method method) { return Modifier.isPublic(method.getModifiers() & method.getDeclaringClass().getModifiers()); } - private static String toString(Method method) { - String prefix = method.getReturnType().getCanonicalName() + - method.getName() + " ("; - - return Stream.of(method.getParameterTypes()) - .map(Class::getCanonicalName) - .collect(Collectors.joining(", ", - prefix, ")")); - } - - private static boolean matches( - Method method, String name, Class... paramTypes) { - + /** + * Check if a method matches the given name and parameter types. + * + * @param method Method to check + * @param name Method name + * @param paramTypes Parameter types + * @return True if the method matches, false otherwise + */ + private static boolean matches(Method method, String name, Class... paramTypes) { return method.getName().equals(name) && Arrays.equals(method.getParameterTypes(), paramTypes); } -} +} \ No newline at end of file From 8cecafa3a9a843816a76f09455ca5b3caa3c6213 Mon Sep 17 00:00:00 2001 From: Farrael Date: Tue, 3 Feb 2026 01:16:02 +0100 Subject: [PATCH 03/30] refactor: handle size and symbol from type --- .../java/au/ellie/hyui/html/ast/Lexer.java | 225 +++++++++++------- .../java/au/ellie/hyui/html/ast/Parser.java | 16 +- .../au/ellie/hyui/html/ast/item/Token.java | 117 ++++++--- .../hyui/html/TemplateProcessorTest.java | 2 +- 4 files changed, 231 insertions(+), 129 deletions(-) diff --git a/src/main/java/au/ellie/hyui/html/ast/Lexer.java b/src/main/java/au/ellie/hyui/html/ast/Lexer.java index d8af27e..f6699bf 100644 --- a/src/main/java/au/ellie/hyui/html/ast/Lexer.java +++ b/src/main/java/au/ellie/hyui/html/ast/Lexer.java @@ -23,13 +23,12 @@ import java.util.ArrayList; import java.util.List; +import static au.ellie.hyui.html.ast.item.Token.Type.*; + public class Lexer { private final String input; - private int pos = 0; - - // May be used for debug private int line = 1; - private int column = 1; + private int pos = 0; public Lexer(String input) { this.input = input; @@ -42,35 +41,49 @@ public List tokenize() { List tokens = new ArrayList<>(); while (pos < input.length()) { - if (peek("{{#")) { - trimWhitespaceForBlock(tokens); - - tokens.add(new Token(Token.Type.BLOCK_OPEN, "{{#", pos)); - advance(3); - tokenizeExpression(tokens); - - skipBlockLineEnd(); - } else if (peek("{{/")) { - trimWhitespaceForBlock(tokens); - - tokens.add(new Token(Token.Type.BLOCK_CLOSE, "{{/", pos)); - advance(3); - tokenizeExpression(tokens); - - skipBlockLineEnd(); - } else if (peek("{{")) { - tokens.add(new Token(Token.Type.EXPR_OPEN, "{{", pos)); - advance(2); - tokenizeExpression(tokens); - } else + if (peek(EXPR_OPEN)) + tokenizeMustache(tokens); + else tokenizeText(tokens); } - tokens.add(new Token(Token.Type.GLOBAL_EOF, "", pos)); + tokens.add(new Token(GLOBAL_EOF, pos)); return tokens; } + /** + * Tokenize mustache-style expressions: `{{ ... }}`, `{{# ... }}` or `{{/ ... }}` + * + * @param tokens The list to add tokens to + */ + private void tokenizeMustache(List tokens) { + boolean clean = false; + + if (peek(BLOCK_START)) { + clean = true; + + trimWhitespaceForBlock(tokens); + tokens.add(new Token(BLOCK_START, pos)); + advance(BLOCK_START); + } else if (peek(BLOCK_END)) { + clean = true; + + trimWhitespaceForBlock(tokens); + tokens.add(new Token(BLOCK_END, pos)); + advance(BLOCK_END); + } else { + tokens.add(new Token(EXPR_OPEN, pos)); + advance(EXPR_OPEN); + } + + skipWhitespace(); + tokenizeExpression(tokens); + + if (clean) + skipBlockLineEnd(); + } + /** * Tokenize an expression until the closing "}}" * @@ -80,18 +93,18 @@ private void tokenizeExpression(List tokens) { skipWhitespace(); while (pos < input.length()) { - if (peek("}}")) + if (peek(EXPR_CLOSE)) break; var current = current(); // String - if (current == '"') { + if (EXPR_STRING.match(current)) { tokens.add(tokenizeString()); } // Variable - else if (current == '$') { + else if (EXPR_VARIABLE.match(current)) { tokens.add(tokenizeVariable()); } @@ -106,39 +119,39 @@ else if (Character.isDigit(current) || } // Keyword / Operator - else if (peek("==")) { - tokens.add(new Token(Token.Type.COMP_EQUALS, "==", pos)); - advance(2); - } else if (peek("!=")) { - tokens.add(new Token(Token.Type.COMP_NOT_EQUALS, "!=", pos)); - advance(2); - } else if (peek("<=")) { - tokens.add(new Token(Token.Type.COMP_LESS_EQUALS, "<=", pos)); - advance(2); - } else if (peek(">=")) { - tokens.add(new Token(Token.Type.COMP_GREATER_EQUALS, ">=", pos)); - advance(2); - } else if (peek("<")) { - tokens.add(new Token(Token.Type.COMP_LESS_THAN, "<", pos)); - advance(1); - } else if (peek(">")) { - tokens.add(new Token(Token.Type.COMP_GREATER_THAN, ">", pos)); - advance(1); - } else if (peek("&&")) { - tokens.add(new Token(Token.Type.COMP_AND, "&&", pos)); - advance(2); - } else if (peek("??")) { - tokens.add(new Token(Token.Type.EXPR_NULL_COALESCING, "??", pos)); - advance(2); - } else if (peek("||")) { - tokens.add(new Token(Token.Type.COMP_OR, "||", pos)); - advance(2); - } else if (peek("|")) { - tokens.add(new Token(Token.Type.EXPR_PIPE, "|", pos)); - advance(1); - } else if (peek(".")) { - tokens.add(new Token(Token.Type.EXPR_VARIABLE_DOT, ".", pos)); - advance(1); + else if (peek(COMP_EQUALS)) { + tokens.add(new Token(COMP_EQUALS, pos)); + advance(COMP_EQUALS); + } else if (peek(COMP_NOT_EQUALS)) { + tokens.add(new Token(COMP_NOT_EQUALS, pos)); + advance(COMP_NOT_EQUALS); + } else if (peek(COMP_LESS_EQUALS)) { + tokens.add(new Token(COMP_LESS_EQUALS, pos)); + advance(COMP_LESS_EQUALS); + } else if (peek(COMP_GREATER_EQUALS)) { + tokens.add(new Token(COMP_GREATER_EQUALS, pos)); + advance(COMP_GREATER_EQUALS); + } else if (peek(COMP_LESS_THAN)) { + tokens.add(new Token(COMP_LESS_THAN, pos)); + advance(COMP_LESS_THAN); + } else if (peek(COMP_GREATER_THAN)) { + tokens.add(new Token(COMP_GREATER_THAN, pos)); + advance(COMP_GREATER_THAN); + } else if (peek(COMP_AND)) { + tokens.add(new Token(COMP_AND, pos)); + advance(COMP_AND); + } else if (peek(EXPR_NULL_COALESCING)) { + tokens.add(new Token(EXPR_NULL_COALESCING, pos)); + advance(EXPR_NULL_COALESCING); + } else if (peek(COMP_OR)) { + tokens.add(new Token(COMP_OR, pos)); + advance(COMP_OR); + } else if (peek(EXPR_PIPE)) { + tokens.add(new Token(EXPR_PIPE, pos)); + advance(EXPR_PIPE); + } else if (peek(EXPR_VARIABLE_DOT)) { + tokens.add(new Token(EXPR_VARIABLE_DOT, pos)); + advance(EXPR_VARIABLE_DOT); } // Identifiers @@ -151,8 +164,8 @@ else if (Character.isLetter(current)) skipWhitespace(); } - if (peek("}}")) { - tokens.add(new Token(Token.Type.EXPR_CLOSE, "}}", pos)); + if (peek(EXPR_CLOSE)) { + tokens.add(new Token(EXPR_CLOSE, pos)); advance(2); } } @@ -162,7 +175,7 @@ else if (Character.isLetter(current)) */ private Token tokenizeString() { int start = pos; - advance(); // Skip opening " + advance(EXPR_STRING); StringBuilder sb = new StringBuilder(); while (pos < input.length() && current() != '"') { @@ -186,9 +199,9 @@ private Token tokenizeString() { if (current() != '"') throwError("Unterminated string", start); - advance(); // Skip closing " + advance(EXPR_STRING); - return new Token(Token.Type.EXPR_STRING, sb.toString(), start); + return new Token(EXPR_STRING, sb.toString(), start); } /** @@ -196,7 +209,7 @@ private Token tokenizeString() { */ private Token tokenizeVariable() { int start = pos; - advance(); // Skip $ + advance(EXPR_VARIABLE); // Skip $ StringBuilder sb = new StringBuilder(); while (pos < input.length() && (Character.isLetterOrDigit(current()) || current() == '_' || current() == '-')) { @@ -204,7 +217,7 @@ private Token tokenizeVariable() { advance(); } - return new Token(Token.Type.EXPR_VARIABLE, sb.toString(), start); + return new Token(EXPR_VARIABLE, sb.toString(), start); } /** @@ -238,7 +251,7 @@ private Token tokenizeNumber() { advance(); } - return new Token(Token.Type.EXPR_NUMBER, sb.toString(), start); + return new Token(EXPR_NUMBER, sb.toString(), start); } /** @@ -255,12 +268,12 @@ private Token tokenizeIdentifier() { String value = sb.toString(); Token.Type type = switch (value) { - case "if" -> Token.Type.BLOCK_IF; - case "else" -> Token.Type.BLOCK_ELSE; - case "each" -> Token.Type.BLOCK_EACH; - case "true", "false" -> Token.Type.EXPR_BOOLEAN; - case "in" -> Token.Type.COMP_IN; - default -> Token.Type.EXPR_IDENTIFIER; + case "if" -> BLOCK_IF; + case "else" -> BLOCK_ELSE; + case "each" -> BLOCK_EACH; + case "true", "false" -> EXPR_BOOLEAN; + case "in" -> COMP_IN; + default -> EXPR_IDENTIFIER; }; return new Token(type, value, start); @@ -275,13 +288,13 @@ private void tokenizeText(List tokens) { int start = pos; StringBuilder sb = new StringBuilder(); - while (pos < input.length() && !peek("{{")) { + while (pos < input.length() && !peek(EXPR_OPEN)) { sb.append(current()); advance(); } if (!sb.isEmpty()) - tokens.add(new Token(Token.Type.GLOBAL_TEXT, sb.toString(), start)); + tokens.add(new Token(GLOBAL_TEXT, sb.toString(), start)); } // ===== Helpers ===== @@ -296,10 +309,29 @@ private char current() { /** * Peeks ahead to see if the next characters match the given string * - * @param str The string to match + * @param str The string(s) to match + */ + private boolean peek(String... str) { + for (var s : str) + if (input.startsWith(s, pos)) + return true; + + return false; + } + + /** + * Peeks ahead to see if the next characters match the given string + * + * @param types The type of token(s) to match */ - private boolean peek(String str) { - return input.startsWith(str, pos); + private boolean peek(Token.Type... types) { + for (var type : types) { + var symbol = type.getSymbol(); + if (symbol != null && input.startsWith(symbol, pos)) + return true; + } + + return false; } /** @@ -309,6 +341,15 @@ private void advance() { advance(1); } + /** + * Advance the current position by one character + */ + private void advance(Token.Type type) { + var symbol = type.getSymbol(); + + advance(symbol != null ? symbol.length() : 0); + } + /** * Advance the current position by count characters * @@ -316,16 +357,24 @@ private void advance() { */ private void advance(int count) { for (int i = 0; i < count && pos < input.length(); i++) { - if (input.charAt(pos) == '\n') { + if (input.charAt(pos) == '\n') line++; - column = 1; - } else - column++; pos++; } } + /** + * Check if current position starts an HTML tag (not just a less-than operator) + */ + private boolean isTagStart() { + if (pos + 1 >= input.length()) return false; + char next = input.charAt(pos + 1); + return Character.isLetter(next) || next == '/'; + } + + // === Whitespace === + /** * Skip whitespace characters */ @@ -345,7 +394,7 @@ private void trimWhitespaceForBlock(List tokens) { return; Token last = tokens.getLast(); - if (last.type() != Token.Type.GLOBAL_TEXT) + if (last.type() != GLOBAL_TEXT) return; String text = last.value(); @@ -361,7 +410,7 @@ private void trimWhitespaceForBlock(List tokens) { String afterLastNewline = text.substring(lastNewlineIndex + 1); if (afterLastNewline.matches("^[ \\t]*$")) { String keepPart = text.substring(0, lastNewlineIndex + 1); - tokens.set(tokens.size() - 1, new Token(Token.Type.GLOBAL_TEXT, keepPart, last.position())); + tokens.set(tokens.size() - 1, new Token(GLOBAL_TEXT, keepPart, last.position())); } } @@ -386,6 +435,8 @@ else if (pos < input.length() && current() == '\r') { pos = start; } + // === Errors === + private String getLine(int lineNumber) { String[] lines = input.split("\\R", -1); // handles \n, \r\n, etc. if (lineNumber < 1 || lineNumber > lines.length) diff --git a/src/main/java/au/ellie/hyui/html/ast/Parser.java b/src/main/java/au/ellie/hyui/html/ast/Parser.java index 9f98738..1558160 100644 --- a/src/main/java/au/ellie/hyui/html/ast/Parser.java +++ b/src/main/java/au/ellie/hyui/html/ast/Parser.java @@ -72,7 +72,7 @@ private Node parseNode() { yield new TextNode(token.value()); } case EXPR_OPEN -> parseExpression(); - case BLOCK_OPEN -> parseBlock(); + case BLOCK_START -> parseBlock(); default -> throw new RuntimeException("Unexpected token: " + token); }; } @@ -228,7 +228,7 @@ private ExpressionNode parsePrimary() { * @return AST node representing the block */ private Node parseBlock() { - expect(BLOCK_OPEN); + expect(BLOCK_START); if (match(BLOCK_IF)) return parseIfBlock(); @@ -248,11 +248,11 @@ private IfBlockNode parseIfBlock() { expect(EXPR_CLOSE); List thenBody = new ArrayList<>(); - while (!check(BLOCK_CLOSE) && !(check(BLOCK_OPEN, BLOCK_ELSE))) + while (!check(BLOCK_END) && !(check(BLOCK_START, BLOCK_ELSE))) thenBody.add(parseNode()); List elseBody = new ArrayList<>(); - if (check(BLOCK_OPEN)) { + if (check(BLOCK_START)) { int savedPos = pos; advance(); // Skip BLOCK_OPEN @@ -260,13 +260,13 @@ private IfBlockNode parseIfBlock() { advance(); // Skip EXPR_ELSE expect(EXPR_CLOSE); - while (!check(BLOCK_CLOSE)) + while (!check(BLOCK_END)) elseBody.add(parseNode()); } else pos = savedPos; } - expect(BLOCK_CLOSE, BLOCK_IF, EXPR_CLOSE); + expect(BLOCK_END, BLOCK_IF, EXPR_CLOSE); return new IfBlockNode(condition, thenBody, elseBody); } @@ -289,10 +289,10 @@ private EachBlockNode parseEachBlock() { expect(EXPR_CLOSE); List body = new ArrayList<>(); - while (!check(BLOCK_CLOSE)) + while (!check(BLOCK_END)) body.add(parseNode()); - expect(BLOCK_CLOSE, BLOCK_EACH, EXPR_CLOSE); + expect(BLOCK_END, BLOCK_EACH, EXPR_CLOSE); return new EachBlockNode(itemName, collection, body); } diff --git a/src/main/java/au/ellie/hyui/html/ast/item/Token.java b/src/main/java/au/ellie/hyui/html/ast/item/Token.java index c4889b6..c2d5430 100644 --- a/src/main/java/au/ellie/hyui/html/ast/item/Token.java +++ b/src/main/java/au/ellie/hyui/html/ast/item/Token.java @@ -18,49 +18,100 @@ package au.ellie.hyui.html.ast.item; +import java.util.Arrays; +import java.util.Objects; + public record Token(Type type, String value, int position) { + + public Token(Type type, int position) { + this(type, type.getSymbol(), position); + } + public enum Type { // Expression - EXPR_OPEN, // {{ - EXPR_CLOSE, // }} - EXPR_VARIABLE, // $name - EXPR_VARIABLE_DOT, // . - EXPR_STRING, // "text" - EXPR_NUMBER, // 123, 45.6 - EXPR_BOOLEAN, // true, false - EXPR_PIPE, // | - EXPR_NULL_COALESCING, // ?? (DEFAULT) - EXPR_IDENTIFIER, // Function name, properties + EXPR_OPEN("{{"), // {{ + EXPR_CLOSE("}}"), // }} + EXPR_VARIABLE("$"), // $name + EXPR_VARIABLE_DOT("."), // . + EXPR_STRING("\""), // "text" + EXPR_NUMBER, // 123, 45.6 + EXPR_BOOLEAN, // true, false + EXPR_PIPE("|"), // | + EXPR_NULL_COALESCING("??"), // ?? (DEFAULT) + EXPR_IDENTIFIER, // Function name, properties // Block - BLOCK_OPEN, // {{# - BLOCK_CLOSE, // {{/ - BLOCK_IF, // if - BLOCK_EACH, // each - BLOCK_ELSE, // else + BLOCK_START("#", EXPR_OPEN), // {{# + BLOCK_END("/", EXPR_OPEN), // {{/ + BLOCK_IF("if"), // if + BLOCK_EACH("each"), // each + BLOCK_ELSE("else"), // else // Html - TAG_OPEN, // < - TAG_CLOSE, // > - TAG_SELF_CLOSE, // /> - TAG_END_OPEN, // "), // > + TAG_SELF_CLOSE("/>"), // /> + TAG_END_OPEN(" - COMP_LESS_EQUALS, // <= - COMP_GREATER_EQUALS, // >= - COMP_IN, // in - COMP_AND, // && - COMP_OR, // || + COMP_EQUALS("=="), // == + COMP_NOT_EQUALS("!="), // != + COMP_LESS_THAN("<"), // < + COMP_GREATER_THAN(">"), // > + COMP_LESS_EQUALS("<="), // <= + COMP_GREATER_EQUALS(">="), // >= + COMP_IN("in"), // in + COMP_AND("&&"), // && + COMP_OR("||"), // || // Special - GLOBAL_ASSIGN, // = - GLOBAL_TEXT, // Text / Html - GLOBAL_EOF + GLOBAL_ASSIGN("="), // = + GLOBAL_TEXT, // Text / Html + GLOBAL_EOF; // End of File + + // ========================== + + private final String symbol; + + Type(String symbol) { + this.symbol = symbol; + } + + Type(String symbol, Type... parents) { + this.symbol = Arrays.stream(parents).map((t) -> t.symbol).reduce("", String::concat) + symbol; + } + + Type() { + this.symbol = null; + } + + /** + * Get the symbol associated with the token type. + */ + public String getSymbol() { + return symbol; + } + + /** + * Check if the token symbol matches the given character. + * + * @param value The value to check against. + */ + public boolean match(Character value) { + return symbol != null && + symbol.length() == 1 && + Objects.equals(symbol.charAt(0), value); + } + + /** + * Check if the token symbol matches the given value. + * + * @param value The value to check against. + */ + public boolean match(String value) { + return Objects.equals(symbol, value); + } } } diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index 767851c..f622735 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -213,7 +213,7 @@ void functionEvaluationOnIfCondition(boolean condition, int value, String expect processor .setVariable("enabled", condition) - .setVariable("secret", (stack) -> { + .setVariable("secret", (_) -> { evaluations.incrementAndGet(); return "value_" + evaluations; }); From 450026eee59de62a30ffe3fbc0b45b0317ac9961 Mon Sep 17 00:00:00 2001 From: Farrael Date: Thu, 5 Feb 2026 23:42:03 +0100 Subject: [PATCH 04/30] feat: handle components and childs with cache --- .../au/ellie/hyui/builders/HyUInterface.java | 25 +- .../java/au/ellie/hyui/html/HtmlParser.java | 45 +- .../au/ellie/hyui/html/TemplateProcessor.java | 145 +++- .../java/au/ellie/hyui/html/ast/Lexer.java | 461 ------------- .../java/au/ellie/hyui/html/ast/Parser.java | 384 ----------- .../hyui/html/ast/context/VariableStack.java | 114 ---- .../au/ellie/hyui/html/ast/item/Token.java | 117 ---- .../html/{ast => template}/Evaluator.java | 160 +++-- .../au/ellie/hyui/html/template/Lexer.java | 629 ++++++++++++++++++ .../au/ellie/hyui/html/template/Parser.java | 526 +++++++++++++++ .../context/FilterRegistry.java | 2 +- .../html/template/context/VariableStack.java | 118 ++++ .../html/{ast => template}/item/Node.java | 46 +- .../hyui/html/template/item/Symbols.java | 73 ++ .../ellie/hyui/html/template/item/Token.java | 84 +++ .../{ast => template}/utils/NumericUtils.java | 62 +- .../utils/ReflectionUtils.java | 2 +- .../hyui/html/TemplateProcessorTest.java | 489 +++++++++----- .../utils/NumericUtilsTest.java | 2 +- 19 files changed, 2083 insertions(+), 1401 deletions(-) delete mode 100644 src/main/java/au/ellie/hyui/html/ast/Lexer.java delete mode 100644 src/main/java/au/ellie/hyui/html/ast/Parser.java delete mode 100644 src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java delete mode 100644 src/main/java/au/ellie/hyui/html/ast/item/Token.java rename src/main/java/au/ellie/hyui/html/{ast => template}/Evaluator.java (63%) create mode 100644 src/main/java/au/ellie/hyui/html/template/Lexer.java create mode 100644 src/main/java/au/ellie/hyui/html/template/Parser.java rename src/main/java/au/ellie/hyui/html/{ast => template}/context/FilterRegistry.java (98%) create mode 100644 src/main/java/au/ellie/hyui/html/template/context/VariableStack.java rename src/main/java/au/ellie/hyui/html/{ast => template}/item/Node.java (67%) create mode 100644 src/main/java/au/ellie/hyui/html/template/item/Symbols.java create mode 100644 src/main/java/au/ellie/hyui/html/template/item/Token.java rename src/main/java/au/ellie/hyui/html/{ast => template}/utils/NumericUtils.java (63%) rename src/main/java/au/ellie/hyui/html/{ast => template}/utils/ReflectionUtils.java (98%) rename src/test/java/au/ellie/hyui/html/{ast => template}/utils/NumericUtilsTest.java (97%) diff --git a/src/main/java/au/ellie/hyui/builders/HyUInterface.java b/src/main/java/au/ellie/hyui/builders/HyUInterface.java index ff8937e..d2ff0c3 100644 --- a/src/main/java/au/ellie/hyui/builders/HyUInterface.java +++ b/src/main/java/au/ellie/hyui/builders/HyUInterface.java @@ -18,9 +18,11 @@ package au.ellie.hyui.builders; +import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.HyUIPluginLogger; import au.ellie.hyui.events.DragCancelledEventData; import au.ellie.hyui.events.DroppedEventData; +import au.ellie.hyui.events.DynamicPageData; import au.ellie.hyui.events.SlotClickPressWhileDraggingEventData; import au.ellie.hyui.events.SlotClickReleaseWhileDraggingEventData; import au.ellie.hyui.events.SlotClickingEventData; @@ -39,8 +41,6 @@ import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; -import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.events.DynamicPageData; import javax.annotation.Nonnull; import java.util.ArrayList; @@ -50,11 +50,12 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.function.Consumer; import java.util.UUID; +import java.util.function.Consumer; public abstract class HyUInterface implements UIContext { + private final Set dirtyValueIds = new HashSet<>(); protected String uiFile; protected List> elements; protected List> editCallbacks; @@ -64,7 +65,6 @@ public abstract class HyUInterface implements UIContext { protected TemplateProcessor templateProcessor; private boolean hasBuilt; private boolean runtimeTemplateUpdatesEnabled; - private final Set dirtyValueIds = new HashSet<>(); public HyUInterface(String uiFile, List> elements, @@ -102,8 +102,9 @@ public Optional getPage() { } @Override - public void updatePage(boolean shouldClose) {} - + public void updatePage(boolean shouldClose) { + } + public void build(@Nonnull Ref ref, @Nonnull UICommandBuilder uiCommandBuilder, @Nonnull UIEventBuilder uiEventBuilder, @@ -116,7 +117,7 @@ public void build(@Nonnull Ref ref, @Nonnull UIEventBuilder uiEventBuilder, @Nonnull Store store, boolean updateOnly) { - + HyUIPlugin.getLog().logFinest("REBUILD: HyUInterface build updateOnly=" + updateOnly); HyUIPlugin.getLog().logFinest("Building HyUInterface" + (uiFile != null ? " from file: " + uiFile : "")); @@ -298,8 +299,8 @@ protected void handleElementEvents(UIElementBuilder element, DynamicPageData if (finalValue != null && userId != null && listener.type() != CustomUIEventBindingType.FocusGained) { //Object previous = elementValues.get(userId); //if (!Objects.equals(previous, finalValue)) { - elementValues.put(userId, finalValue); - dirtyValueIds.add(userId); + elementValues.put(userId, finalValue); + dirtyValueIds.add(userId); //} } @@ -445,9 +446,9 @@ private void refreshTemplate(UIContext context) { } HyUIPlugin.getLog().logFinest("REBUILD: Template refresh"); HtmlParser parser = new HtmlParser(); - String processedHtml = templateProcessor.process(templateHtml, context); + String processedHtml = templateProcessor.setTemplate(templateHtml).process(context); List> updatedElements = parser.parse(processedHtml); - + this.elements = mergeElementLists(this.elements, updatedElements); applyRuntimeValues(this.elements, context); reapplyTabSelections(this.elements, context); @@ -457,7 +458,7 @@ private void refreshTemplate(UIContext context) { } private List> mergeElementLists(List> currentElements, - List> updatedElements) { + List> updatedElements) { /* for (var e : updatedElements) { HyUIPlugin.getLog().logInfo("UPDATED ELEMENT: \n\n" + e); diff --git a/src/main/java/au/ellie/hyui/html/HtmlParser.java b/src/main/java/au/ellie/hyui/html/HtmlParser.java index e5c086f..9740b50 100644 --- a/src/main/java/au/ellie/hyui/html/HtmlParser.java +++ b/src/main/java/au/ellie/hyui/html/HtmlParser.java @@ -19,10 +19,25 @@ package au.ellie.hyui.html; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.builders.LabelBuilder; import au.ellie.hyui.builders.InterfaceBuilder; +import au.ellie.hyui.builders.LabelBuilder; import au.ellie.hyui.builders.UIElementBuilder; -import au.ellie.hyui.html.handlers.*; +import au.ellie.hyui.html.handlers.ButtonHandler; +import au.ellie.hyui.html.handlers.DivHandler; +import au.ellie.hyui.html.handlers.HyvatarHandler; +import au.ellie.hyui.html.handlers.ImgHandler; +import au.ellie.hyui.html.handlers.InputHandler; +import au.ellie.hyui.html.handlers.ItemGridHandler; +import au.ellie.hyui.html.handlers.ItemIconHandler; +import au.ellie.hyui.html.handlers.ItemSlotHandler; +import au.ellie.hyui.html.handlers.LabelHandler; +import au.ellie.hyui.html.handlers.ProgressBarHandler; +import au.ellie.hyui.html.handlers.SelectHandler; +import au.ellie.hyui.html.handlers.SpriteHandler; +import au.ellie.hyui.html.handlers.TabContentHandler; +import au.ellie.hyui.html.handlers.TabNavigationHandler; +import au.ellie.hyui.html.handlers.TextAreaHandler; +import au.ellie.hyui.html.handlers.TimerHandler; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -38,7 +53,7 @@ public class HtmlParser { private final List handlers = new ArrayList<>(); private TemplateProcessor templateProcessor; - + public HtmlParser() { // Register default handlers registerHandler(new ItemGridHandler()); @@ -69,23 +84,23 @@ public void registerHandler(TagHandler handler) { } /** - * Sets the template processor for variable interpolation and component inclusion. + * Gets the current template processor. * - * @param processor The template processor to use. + * @return The template processor, or null if not set. */ - public void setTemplateProcessor(TemplateProcessor processor) { - this.templateProcessor = processor; + public TemplateProcessor getTemplateProcessor() { + return templateProcessor; } /** - * Gets the current template processor. + * Sets the template processor for variable interpolation and component inclusion. * - * @return The template processor, or null if not set. + * @param processor The template processor to use. */ - public TemplateProcessor getTemplateProcessor() { - return templateProcessor; + public void setTemplateProcessor(TemplateProcessor processor) { + this.templateProcessor = processor; } - + /** * Parses the HTML string and adds elements to the InterfaceBuilder. * @@ -109,7 +124,7 @@ public List> parse(String html) { // Apply template processing if a processor is set String processedHtml = html; if (templateProcessor != null) { - processedHtml = templateProcessor.process(html); + processedHtml = templateProcessor.setTemplate(html).process(); HyUIPlugin.getLog().logFinest("Processed template: " + processedHtml); } Document doc = Jsoup.parseBodyFragment(processedHtml); @@ -128,10 +143,10 @@ public List> parseChildren(Element parent) { List> builders = new ArrayList<>(); for (Node child : parent.childNodes()) { HyUIPlugin.getLog().logFinest("Parsing child node: " + child.nodeName()); - + if (child instanceof Element) { HyUIPlugin.getLog().logFinest("Parsing ELEMENT node: " + child.nodeName()); - + UIElementBuilder builder = handleElement((Element) child); if (builder != null) { HyUIPlugin.getLog().logFinest("Parsed element: " + builder.getClass().getSimpleName()); diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index ea186dd..5dbc3b7 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -20,14 +20,15 @@ import au.ellie.hyui.builders.UIElementBuilder; import au.ellie.hyui.events.UIContext; -import au.ellie.hyui.html.ast.Evaluator; -import au.ellie.hyui.html.ast.Lexer; -import au.ellie.hyui.html.ast.Parser; -import au.ellie.hyui.html.ast.context.FilterRegistry; -import au.ellie.hyui.html.ast.context.VariableStack; -import au.ellie.hyui.html.ast.item.Node; -import au.ellie.hyui.html.ast.item.Token; - +import au.ellie.hyui.html.template.Evaluator; +import au.ellie.hyui.html.template.Lexer; +import au.ellie.hyui.html.template.Parser; +import au.ellie.hyui.html.template.context.FilterRegistry; +import au.ellie.hyui.html.template.context.VariableStack; +import au.ellie.hyui.html.template.item.Node; +import au.ellie.hyui.html.template.item.Token; + +import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.io.IOException; import java.io.InputStream; @@ -38,10 +39,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.Function; import java.util.function.Supplier; +import static au.ellie.hyui.html.template.context.VariableStack.NULL_SENTINEL; + /** * Preprocessor for HyUIML templates that supports variable interpolation and component inclusion. * @@ -73,15 +77,25 @@ * */ public class TemplateProcessor { - - private static final Object NULL_SENTINEL = new Object(); - private final Map variables = new HashMap<>(); - private final Map components = new HashMap<>(); + private final Map components = new HashMap<>(); private final FilterRegistry filterRegistry = new FilterRegistry(); + private final CachedComponent root = new CachedComponent("__root__"); + private ValueResolver valueResolver; private boolean preferDynamicValues; + /** + * Sets the template string to be processed. + * + * @param template The template string + */ + public TemplateProcessor setTemplate(String template) { + root.setTemplate(template); + + return this; + } + /** * Sets a template variable from any object. * @@ -154,8 +168,19 @@ public TemplateProcessor registerFilter(String name, FilterRegistry.Filter filte * @param template Component HTML template * @return This processor for chaining */ - public TemplateProcessor registerComponent(String name, String template) { - components.put(name, template); + public TemplateProcessor registerComponent(@Nonnull String name, @Nonnull String template) { + assert !name.isEmpty() : "Component name cannot be empty."; + assert !template.isEmpty() : "Component template cannot be empty."; + + + var cache = components.computeIfAbsent(name, _ -> new CachedComponent(name)); + var updated = cache.setTemplate(template); + + // Invalidate other components cache + if (updated && root.invalidate()) + for (Map.Entry entry : components.entrySet()) + entry.getValue().invalidate(); + return this; } @@ -178,21 +203,19 @@ public TemplateProcessor registerComponentFromFile(String name, String resourceP /** * Process a template with the current variables. * - * @param template The template string. * @return The processed template. */ - public String process(String template) { - return process(template, (Map) null); + public String process() { + return process((Map) null); } /** * Processes the template using the provided UI context to resolve element IDs. * - * @param template The template string - * @param context The UI context for runtime values + * @param context The UI context for runtime values * @return Processed HTML string */ - public String process(String template, @Nullable UIContext context) { + public String process(@Nullable UIContext context) { ValueResolver previousResolver = this.valueResolver; boolean previousPreferDynamic = this.preferDynamicValues; this.valueResolver = name -> { @@ -208,25 +231,30 @@ public String process(String template, @Nullable UIContext context) { this.preferDynamicValues = true; try { - return process(template, new HashMap<>(variables)); + return process(new HashMap<>(variables)); } finally { this.valueResolver = previousResolver; this.preferDynamicValues = previousPreferDynamic; } } - public String process(String template, @Nullable Map additionalVariables) { - // Lexer / Parser - List tokens = new Lexer(template).tokenize(); - List ast = new Parser(tokens).parse(); - + /** + * Processes the template with additional variables that can override existing ones. + * + * @param additionalVariables Additional variables to use during processing (can override existing variables) + * @return The processed template. + */ + public String process(@Nullable Map additionalVariables) { // Inject additional variables, this allows for per-call variable overrides Map parameters = additionalVariables == null ? variables : new HashMap<>(variables); if (additionalVariables != null) parameters.putAll(additionalVariables); + var stack = new VariableStack(parameters, valueResolver, preferDynamicValues); + var rootAst = this.root.getAst(components); + // Evaluator - return new Evaluator(parameters, filterRegistry).evaluate(ast); + return new Evaluator(stack, filterRegistry, components).evaluate(rootAst); } // ===== Internal ===== @@ -288,4 +316,67 @@ private boolean hasElement(UIContext context, String name) { public interface ValueResolver { Optional resolve(String name); } + + public static class CachedComponent { + private List ast; + private String template; + private String name; + + public CachedComponent(String name) { + this.template = ""; + this.name = name; + } + + /** + * Gets the current template string for this component. + */ + public String getTemplate() { + return template; + } + + /** + * Sets the template string for this component + * and invalidates the cached AST if the template has changed. + * + * @param template The new template string + * @return True if the template was updated, false if the template was unchanged. + */ + public boolean setTemplate(String template) { + if (!Objects.equals(template, this.template)) { + this.template = template; + this.ast = null; // Invalidate cache + return true; + } + + return false; + } + + /** + * Invalidates the cached AST for this component. + * Should be called if the template is modified externally after being set. + * + * @return True if the cache was invalidated, false if it was already null. + */ + public boolean invalidate() { + boolean wasCached = this.ast != null; + ast = null; + + return wasCached; + } + + /** + * Retrieve the AST for this component, + * parsing the template if it hasn't been parsed yet. + * + * @return The built template processor. + */ + public List getAst(Map componentCache) { + if (ast == null) { + List tokens = new Lexer(template, componentCache, name).tokenize(); + ast = new Parser(tokens, componentCache).parse(); + } + + return ast; + } + } } diff --git a/src/main/java/au/ellie/hyui/html/ast/Lexer.java b/src/main/java/au/ellie/hyui/html/ast/Lexer.java deleted file mode 100644 index f6699bf..0000000 --- a/src/main/java/au/ellie/hyui/html/ast/Lexer.java +++ /dev/null @@ -1,461 +0,0 @@ -/* - * Copyright (C) 2026 EllieAU - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - * - */ - -package au.ellie.hyui.html.ast; - -import au.ellie.hyui.html.ast.item.Token; - -import java.util.ArrayList; -import java.util.List; - -import static au.ellie.hyui.html.ast.item.Token.Type.*; - -public class Lexer { - private final String input; - private int line = 1; - private int pos = 0; - - public Lexer(String input) { - this.input = input; - } - - /** - * Tokenize the input string into a list of tokens - */ - public List tokenize() { - List tokens = new ArrayList<>(); - - while (pos < input.length()) { - if (peek(EXPR_OPEN)) - tokenizeMustache(tokens); - else - tokenizeText(tokens); - } - - tokens.add(new Token(GLOBAL_EOF, pos)); - - return tokens; - } - - /** - * Tokenize mustache-style expressions: `{{ ... }}`, `{{# ... }}` or `{{/ ... }}` - * - * @param tokens The list to add tokens to - */ - private void tokenizeMustache(List tokens) { - boolean clean = false; - - if (peek(BLOCK_START)) { - clean = true; - - trimWhitespaceForBlock(tokens); - tokens.add(new Token(BLOCK_START, pos)); - advance(BLOCK_START); - } else if (peek(BLOCK_END)) { - clean = true; - - trimWhitespaceForBlock(tokens); - tokens.add(new Token(BLOCK_END, pos)); - advance(BLOCK_END); - } else { - tokens.add(new Token(EXPR_OPEN, pos)); - advance(EXPR_OPEN); - } - - skipWhitespace(); - tokenizeExpression(tokens); - - if (clean) - skipBlockLineEnd(); - } - - /** - * Tokenize an expression until the closing "}}" - * - * @param tokens The list to add tokens to - */ - private void tokenizeExpression(List tokens) { - skipWhitespace(); - - while (pos < input.length()) { - if (peek(EXPR_CLOSE)) - break; - - var current = current(); - - // String - if (EXPR_STRING.match(current)) { - tokens.add(tokenizeString()); - } - - // Variable - else if (EXPR_VARIABLE.match(current)) { - tokens.add(tokenizeVariable()); - } - - // Numbers - else if (Character.isDigit(current) || - (current == '-' && - pos + 1 < input.length() && - Character.isDigit(input.charAt(pos + 1)) - ) - ) { - tokens.add(tokenizeNumber()); - } - - // Keyword / Operator - else if (peek(COMP_EQUALS)) { - tokens.add(new Token(COMP_EQUALS, pos)); - advance(COMP_EQUALS); - } else if (peek(COMP_NOT_EQUALS)) { - tokens.add(new Token(COMP_NOT_EQUALS, pos)); - advance(COMP_NOT_EQUALS); - } else if (peek(COMP_LESS_EQUALS)) { - tokens.add(new Token(COMP_LESS_EQUALS, pos)); - advance(COMP_LESS_EQUALS); - } else if (peek(COMP_GREATER_EQUALS)) { - tokens.add(new Token(COMP_GREATER_EQUALS, pos)); - advance(COMP_GREATER_EQUALS); - } else if (peek(COMP_LESS_THAN)) { - tokens.add(new Token(COMP_LESS_THAN, pos)); - advance(COMP_LESS_THAN); - } else if (peek(COMP_GREATER_THAN)) { - tokens.add(new Token(COMP_GREATER_THAN, pos)); - advance(COMP_GREATER_THAN); - } else if (peek(COMP_AND)) { - tokens.add(new Token(COMP_AND, pos)); - advance(COMP_AND); - } else if (peek(EXPR_NULL_COALESCING)) { - tokens.add(new Token(EXPR_NULL_COALESCING, pos)); - advance(EXPR_NULL_COALESCING); - } else if (peek(COMP_OR)) { - tokens.add(new Token(COMP_OR, pos)); - advance(COMP_OR); - } else if (peek(EXPR_PIPE)) { - tokens.add(new Token(EXPR_PIPE, pos)); - advance(EXPR_PIPE); - } else if (peek(EXPR_VARIABLE_DOT)) { - tokens.add(new Token(EXPR_VARIABLE_DOT, pos)); - advance(EXPR_VARIABLE_DOT); - } - - // Identifiers - else if (Character.isLetter(current)) - tokens.add(tokenizeIdentifier()); - - else - throwError("Unexpected character: " + current(), pos); - - skipWhitespace(); - } - - if (peek(EXPR_CLOSE)) { - tokens.add(new Token(EXPR_CLOSE, pos)); - advance(2); - } - } - - /** - * Tokenize a string literal - */ - private Token tokenizeString() { - int start = pos; - advance(EXPR_STRING); - - StringBuilder sb = new StringBuilder(); - while (pos < input.length() && current() != '"') { - if (current() == '\\' && pos + 1 < input.length()) { - advance(); - char escaped = current(); - switch (escaped) { - case 'n' -> sb.append('\n'); - case 't' -> sb.append('\t'); - case '"' -> sb.append('"'); - case '\\' -> sb.append('\\'); - default -> sb.append(escaped); - } - advance(); - } else { - sb.append(current()); - advance(); - } - } - - if (current() != '"') - throwError("Unterminated string", start); - - advance(EXPR_STRING); - - return new Token(EXPR_STRING, sb.toString(), start); - } - - /** - * Tokenize a variable (starts with $) - */ - private Token tokenizeVariable() { - int start = pos; - advance(EXPR_VARIABLE); // Skip $ - StringBuilder sb = new StringBuilder(); - - while (pos < input.length() && (Character.isLetterOrDigit(current()) || current() == '_' || current() == '-')) { - sb.append(current()); - advance(); - } - - return new Token(EXPR_VARIABLE, sb.toString(), start); - } - - /** - * Tokenize a number (integer or decimal) - */ - private Token tokenizeNumber() { - StringBuilder sb = new StringBuilder(); - if (current() == '-') { - sb.append(current()); - advance(); - } - - // Must have at least one digit after the sign - int start = pos; - if (!Character.isDigit(current())) { - pos = start; - - throwError("Expected digit after '-'", pos); - } - - boolean hasDecimal = false; - while (pos < input.length() && (Character.isDigit(current()) || current() == '.')) { - if (current() == '.') { - if (hasDecimal) - break; - - hasDecimal = true; - } - - sb.append(current()); - advance(); - } - - return new Token(EXPR_NUMBER, sb.toString(), start); - } - - /** - * Tokenize an identifier or keyword - */ - private Token tokenizeIdentifier() { - int start = pos; - - StringBuilder sb = new StringBuilder(); - while (pos < input.length() && (Character.isLetterOrDigit(current()) || current() == '_' || current() == '-')) { - sb.append(current()); - advance(); - } - - String value = sb.toString(); - Token.Type type = switch (value) { - case "if" -> BLOCK_IF; - case "else" -> BLOCK_ELSE; - case "each" -> BLOCK_EACH; - case "true", "false" -> EXPR_BOOLEAN; - case "in" -> COMP_IN; - default -> EXPR_IDENTIFIER; - }; - - return new Token(type, value, start); - } - - /** - * Tokenize plain text until the next "{{" - * - * @param tokens The list to add tokens to - */ - private void tokenizeText(List tokens) { - int start = pos; - - StringBuilder sb = new StringBuilder(); - while (pos < input.length() && !peek(EXPR_OPEN)) { - sb.append(current()); - advance(); - } - - if (!sb.isEmpty()) - tokens.add(new Token(GLOBAL_TEXT, sb.toString(), start)); - } - - // ===== Helpers ===== - - /** - * Returns the current character or '\0' if at the end of input - */ - private char current() { - return pos < input.length() ? input.charAt(pos) : '\0'; - } - - /** - * Peeks ahead to see if the next characters match the given string - * - * @param str The string(s) to match - */ - private boolean peek(String... str) { - for (var s : str) - if (input.startsWith(s, pos)) - return true; - - return false; - } - - /** - * Peeks ahead to see if the next characters match the given string - * - * @param types The type of token(s) to match - */ - private boolean peek(Token.Type... types) { - for (var type : types) { - var symbol = type.getSymbol(); - if (symbol != null && input.startsWith(symbol, pos)) - return true; - } - - return false; - } - - /** - * Advance the current position by one character - */ - private void advance() { - advance(1); - } - - /** - * Advance the current position by one character - */ - private void advance(Token.Type type) { - var symbol = type.getSymbol(); - - advance(symbol != null ? symbol.length() : 0); - } - - /** - * Advance the current position by count characters - * - * @param count Number of characters to advance - */ - private void advance(int count) { - for (int i = 0; i < count && pos < input.length(); i++) { - if (input.charAt(pos) == '\n') - line++; - - pos++; - } - } - - /** - * Check if current position starts an HTML tag (not just a less-than operator) - */ - private boolean isTagStart() { - if (pos + 1 >= input.length()) return false; - char next = input.charAt(pos + 1); - return Character.isLetter(next) || next == '/'; - } - - // === Whitespace === - - /** - * Skip whitespace characters - */ - private void skipWhitespace() { - while (pos < input.length() && Character.isWhitespace(current())) - advance(); - } - - /** - * Trim trailing whitespace from the last text token in a block - * if it only contains whitespace after the last newline - * - * @param tokens The list of tokens to trim - */ - private void trimWhitespaceForBlock(List tokens) { - if (tokens.isEmpty()) - return; - - Token last = tokens.getLast(); - if (last.type() != GLOBAL_TEXT) - return; - - String text = last.value(); - int lastNewlineIndex = text.lastIndexOf('\n'); - - if (lastNewlineIndex == -1) { - if (tokens.size() == 1 && text.matches("^[ \\t]+$")) - tokens.removeFirst(); - - return; - } - - String afterLastNewline = text.substring(lastNewlineIndex + 1); - if (afterLastNewline.matches("^[ \\t]*$")) { - String keepPart = text.substring(0, lastNewlineIndex + 1); - tokens.set(tokens.size() - 1, new Token(GLOBAL_TEXT, keepPart, last.position())); - } - } - - /** - * Skip whitespace and a newline if present after a standalone tag - */ - private void skipBlockLineEnd() { - int start = pos; - - // Skip spaces and tabs - while (pos < input.length() && (current() == ' ' || current() == '\t')) - advance(); - - // Check for newline - if (pos < input.length() && current() == '\n') - advance(); - else if (pos < input.length() && current() == '\r') { - advance(); - if (pos < input.length() && current() == '\n') - advance(); - } else - pos = start; - } - - // === Errors === - - private String getLine(int lineNumber) { - String[] lines = input.split("\\R", -1); // handles \n, \r\n, etc. - if (lineNumber < 1 || lineNumber > lines.length) - return ""; - - return lines[lineNumber - 1]; - } - - private void throwError(String message, int errorPos) { - var arrow = " ".repeat(Math.max(0, errorPos)) + - "↳ " + message; - - String formattedMessage = String.format(""" - An error occurred when parsing the input at line %d, column %d - %s - %s - """, line, errorPos, getLine(line), arrow - ); - - throw new RuntimeException(formattedMessage); - } -} diff --git a/src/main/java/au/ellie/hyui/html/ast/Parser.java b/src/main/java/au/ellie/hyui/html/ast/Parser.java deleted file mode 100644 index 1558160..0000000 --- a/src/main/java/au/ellie/hyui/html/ast/Parser.java +++ /dev/null @@ -1,384 +0,0 @@ -/* - * Copyright (C) 2026 EllieAU - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - * - */ - -package au.ellie.hyui.html.ast; - -import au.ellie.hyui.html.ast.item.Node; -import au.ellie.hyui.html.ast.item.Node.BlockNode.EachBlockNode; -import au.ellie.hyui.html.ast.item.Node.BlockNode.IfBlockNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.BinaryOpNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.DefaultNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.LiteralNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PipeNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PropertyAccessNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.TextNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.VariableNode; -import au.ellie.hyui.html.ast.item.Token; - -import java.util.ArrayList; -import java.util.List; - -import static au.ellie.hyui.html.ast.item.Token.Type.*; - -public class Parser { - private final List tokens; - private int pos = 0; - - public Parser(List tokens) { - this.tokens = tokens; - } - - /** - * Parse the list of tokens into an AST - * - * @return List of AST nodes - */ - public List parse() { - List nodes = new ArrayList<>(); - - while (!isAtEnd()) - nodes.add(parseNode()); - - return nodes; - } - - /** - * Parse a single AST node - * - * @return AST node - */ - private Node parseNode() { - Token token = current(); - - return switch (token.type()) { - case GLOBAL_TEXT -> { - advance(); - yield new TextNode(token.value()); - } - case EXPR_OPEN -> parseExpression(); - case BLOCK_START -> parseBlock(); - default -> throw new RuntimeException("Unexpected token: " + token); - }; - } - - /** - * Parse an expression - * - * @return AST node representing the expression - */ - private Node parseExpression() { - expect(EXPR_OPEN); - ExpressionNode expr = parseExpressionContent(); - expect(EXPR_CLOSE); - return expr; - } - - /** - * Parse the content of an expression - * - * @return Expression node - */ - private ExpressionNode parseExpressionContent() { - return parseDefault(); - } - - /** - * Parse `nullish` coalescing expressions - * - * @return Expression node - */ - private ExpressionNode parseDefault() { - List alternatives = new ArrayList<>(); - - do { - alternatives.add(parseOr()); - } while (match(EXPR_NULL_COALESCING)); - - return alternatives.size() == 1 ? alternatives.getFirst() : new DefaultNode(alternatives); - } - - /** - * Parse logical `OR` expressions - * - * @return Expression node - */ - private ExpressionNode parseOr() { - ExpressionNode left = parseAnd(); - - while (match(COMP_OR)) { - Token operator = previous(); - ExpressionNode right = parseAnd(); - left = new BinaryOpNode(left, operator.type(), right); - } - - return left; - } - - /** - * Parse logical `AND` expressions - * - * @return Expression node - */ - private ExpressionNode parseAnd() { - ExpressionNode left = parseComparison(); - - while (match(COMP_AND)) { - Token operator = previous(); - ExpressionNode right = parseComparison(); - left = new BinaryOpNode(left, operator.type(), right); - } - - return left; - } - - /** - * Parse `comparison` expressions - * - * @return Expression node - */ - private ExpressionNode parseComparison() { - ExpressionNode left = parsePipe(); - - if (match(COMP_EQUALS, COMP_NOT_EQUALS, COMP_LESS_THAN, - COMP_GREATER_THAN, COMP_LESS_EQUALS, COMP_GREATER_EQUALS, - COMP_IN)) { - Token operator = previous(); - ExpressionNode right = parsePipe(); - return new BinaryOpNode(left, operator.type(), right); - } - - return left; - } - - /** - * Parse `pipe` expressions - * - * @return Expression node - */ - private ExpressionNode parsePipe() { - ExpressionNode expr = parsePrimary(); - - while (match(EXPR_PIPE)) { - String filterName = expect(EXPR_IDENTIFIER).value(); - expr = new PipeNode(expr, filterName); - } - - return expr; - } - - /** - * Parse primary expressions (literals, variables, property access) - * - * @return Expression node - */ - private ExpressionNode parsePrimary() { - // String literal - if (match(EXPR_STRING)) - return new LiteralNode(previous().value()); - - // Number literal - if (match(EXPR_NUMBER)) { - String value = previous().value(); - - if (value.contains(".")) - return new LiteralNode(Double.parseDouble(value)); - else - return new LiteralNode(Long.parseLong(value)); - } - - // Boolean literal - if (match(EXPR_BOOLEAN)) - return new LiteralNode(Boolean.parseBoolean(previous().value())); - - // Variable with property access - if (match(EXPR_VARIABLE)) { - String varName = previous().value(); - ExpressionNode expr = new VariableNode(varName); - - while (match(EXPR_VARIABLE_DOT)) { - String property = expect(EXPR_IDENTIFIER).value(); - expr = new PropertyAccessNode(expr, property); - } - - return expr; - } - - throw new RuntimeException("Unexpected token in expression: " + current()); - } - - /** - * Parse a block (if, each, etc.) - * - * @return AST node representing the block - */ - private Node parseBlock() { - expect(BLOCK_START); - - if (match(BLOCK_IF)) - return parseIfBlock(); - else if (match(BLOCK_EACH)) - return parseEachBlock(); - - throw new RuntimeException("Unknown block type: " + current()); - } - - /** - * Parse an `if` block - * - * @return IfBlockNode - */ - private IfBlockNode parseIfBlock() { - ExpressionNode condition = parseExpressionContent(); - expect(EXPR_CLOSE); - - List thenBody = new ArrayList<>(); - while (!check(BLOCK_END) && !(check(BLOCK_START, BLOCK_ELSE))) - thenBody.add(parseNode()); - - List elseBody = new ArrayList<>(); - if (check(BLOCK_START)) { - int savedPos = pos; - advance(); // Skip BLOCK_OPEN - - if (check(BLOCK_ELSE)) { - advance(); // Skip EXPR_ELSE - expect(EXPR_CLOSE); - - while (!check(BLOCK_END)) - elseBody.add(parseNode()); - } else - pos = savedPos; - } - - expect(BLOCK_END, BLOCK_IF, EXPR_CLOSE); - - return new IfBlockNode(condition, thenBody, elseBody); - } - - /** - * Parse an `each` block - * - * @return EachBlockNode - */ - private EachBlockNode parseEachBlock() { - // Syntaxe : {{#each $collection}} or {{#each $collection customName}} - // Optional name, default to "item" - - ExpressionNode collection = parseExpressionContent(); - - String itemName = "item"; - if (check(EXPR_IDENTIFIER)) - itemName = expect(EXPR_IDENTIFIER).value(); - - expect(EXPR_CLOSE); - - List body = new ArrayList<>(); - while (!check(BLOCK_END)) - body.add(parseNode()); - - expect(BLOCK_END, BLOCK_EACH, EXPR_CLOSE); - - return new EachBlockNode(itemName, collection, body); - } - - // ===== Helpers ===== - - /** - * Get the current token - */ - private Token current() { - return tokens.get(pos); - } - - /** - * Get the previous token - */ - private Token previous() { - return tokens.get(pos - 1); - } - - /** - * Get the next token - */ - private Token next() { - return tokens.get(pos + 1); - } - - /** - * Consume the current token and return it - */ - private Token advance() { - if (!isAtEnd()) pos++; - return previous(); - } - - /** - * If the current token matches any of the given types, consume it and return true. - * Otherwise, return false. - */ - private boolean match(Token.Type... types) { - for (Token.Type type : types) { - if (check(type)) { - advance(); - return true; - } - } - - return false; - } - - /** - * Check if token matches the given types, without consuming them. - * Starts from the current token and checks each type in order. - */ - private boolean check(Token.Type... types) { - var index = pos; - for (Token.Type type : types) { - if (tokens.get(index++).type() != type) - return false; - } - - return true; - } - - /** - * Except the tokens to match the given types, consuming them. - * Starts from the current token and checks each type in order. - * - * @throws RuntimeException if any of the expected types do not match - */ - private Token expect(Token.Type... types) { - Token token = current(); - for (Token.Type type : types) { - if (check(type)) - token = advance(); - else - throw new RuntimeException("Expected " + type + " but got " + current().type() + " at position " + current().position()); - } - - return token; - } - - /** - * Check if we have reached the end of the token list - */ - private boolean isAtEnd() { - return current().type() == GLOBAL_EOF; - } -} diff --git a/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java deleted file mode 100644 index 74ec4e5..0000000 --- a/src/main/java/au/ellie/hyui/html/ast/context/VariableStack.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (C) 2026 EllieAU - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - * - */ - -package au.ellie.hyui.html.ast.context; - -import java.util.ArrayDeque; -import java.util.Map; -import java.util.function.Function; -import java.util.function.Supplier; - -public interface VariableStack { - - /** - * Retrieve a variable from the stack. - * - * @param name Variable name - * @return Variable value, or null if not found - */ - Object getVariable(String name); - - /** - * Retrieve a variable from the stack, with a default value. - * - * @param name Variable name - * @param defaultValue Default value if variable not found - * @return Variable value, or defaultValue if not found - */ - Object getVariable(String name, Object defaultValue); - - // ========== INTERNAL RECORDS ========== - - sealed interface VariableValue permits Value, Lazy, Computed { - } - - record Value(Object value) implements VariableValue { - } - - record Lazy(Supplier supplier) implements VariableValue { - } - - record Computed(Function function) implements VariableValue { - } - - // ========== IMPLEMENTATION ========== - - class VariableStackImpl implements VariableStack { - private final ArrayDeque> stack = new ArrayDeque<>(); - - public VariableStackImpl(Map globalScope) { - pushScope(globalScope); - } - - public void pushScope(Map scope) { - stack.push(scope); - } - - public void popScope() { - if (stack.size() > 1) - stack.pop(); - else - throw new IllegalStateException("Cannot pop the global scope"); - } - - public Object getVariable(String name) { - return getVariable(name, null); - } - - public Object getVariable(String name, Object defaultValue) { - for (Map scope : stack) { - if (scope.containsKey(name)) { - var object = scope.get(name); - - switch (object) { - case Supplier supplier -> { - object = supplier.get(); - scope.put(name, object); - return object; - } - case Function function -> { - @SuppressWarnings("unchecked") - Function stackFunction = - (Function) function; - - return stackFunction.apply(this); - } - case null -> { - return null; - } - default -> { - return object; - } - } - } - } - - return defaultValue; - } - } -} diff --git a/src/main/java/au/ellie/hyui/html/ast/item/Token.java b/src/main/java/au/ellie/hyui/html/ast/item/Token.java deleted file mode 100644 index c2d5430..0000000 --- a/src/main/java/au/ellie/hyui/html/ast/item/Token.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2026 EllieAU - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program. If not, see . - * - */ - -package au.ellie.hyui.html.ast.item; - -import java.util.Arrays; -import java.util.Objects; - -public record Token(Type type, String value, int position) { - - public Token(Type type, int position) { - this(type, type.getSymbol(), position); - } - - public enum Type { - // Expression - EXPR_OPEN("{{"), // {{ - EXPR_CLOSE("}}"), // }} - EXPR_VARIABLE("$"), // $name - EXPR_VARIABLE_DOT("."), // . - EXPR_STRING("\""), // "text" - EXPR_NUMBER, // 123, 45.6 - EXPR_BOOLEAN, // true, false - EXPR_PIPE("|"), // | - EXPR_NULL_COALESCING("??"), // ?? (DEFAULT) - EXPR_IDENTIFIER, // Function name, properties - - // Block - BLOCK_START("#", EXPR_OPEN), // {{# - BLOCK_END("/", EXPR_OPEN), // {{/ - BLOCK_IF("if"), // if - BLOCK_EACH("each"), // each - BLOCK_ELSE("else"), // else - - // Html - TAG_OPEN("<"), // < - TAG_CLOSE(">"), // > - TAG_SELF_CLOSE("/>"), // /> - TAG_END_OPEN(""), // > - COMP_LESS_EQUALS("<="), // <= - COMP_GREATER_EQUALS(">="), // >= - COMP_IN("in"), // in - COMP_AND("&&"), // && - COMP_OR("||"), // || - - // Special - GLOBAL_ASSIGN("="), // = - GLOBAL_TEXT, // Text / Html - GLOBAL_EOF; // End of File - - // ========================== - - private final String symbol; - - Type(String symbol) { - this.symbol = symbol; - } - - Type(String symbol, Type... parents) { - this.symbol = Arrays.stream(parents).map((t) -> t.symbol).reduce("", String::concat) + symbol; - } - - Type() { - this.symbol = null; - } - - /** - * Get the symbol associated with the token type. - */ - public String getSymbol() { - return symbol; - } - - /** - * Check if the token symbol matches the given character. - * - * @param value The value to check against. - */ - public boolean match(Character value) { - return symbol != null && - symbol.length() == 1 && - Objects.equals(symbol.charAt(0), value); - } - - /** - * Check if the token symbol matches the given value. - * - * @param value The value to check against. - */ - public boolean match(String value) { - return Objects.equals(symbol, value); - } - } -} diff --git a/src/main/java/au/ellie/hyui/html/ast/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java similarity index 63% rename from src/main/java/au/ellie/hyui/html/ast/Evaluator.java rename to src/main/java/au/ellie/hyui/html/template/Evaluator.java index e2561b7..104e4c7 100644 --- a/src/main/java/au/ellie/hyui/html/ast/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -16,26 +16,31 @@ * */ -package au.ellie.hyui.html.ast; +package au.ellie.hyui.html.template; import au.ellie.hyui.HyUIPlugin; -import au.ellie.hyui.html.ast.context.FilterRegistry; -import au.ellie.hyui.html.ast.context.VariableStack.VariableStackImpl; -import au.ellie.hyui.html.ast.item.Node; -import au.ellie.hyui.html.ast.item.Node.BlockNode.EachBlockNode; -import au.ellie.hyui.html.ast.item.Node.BlockNode.IfBlockNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.BinaryOpNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.DefaultNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.LiteralNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PipeNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.PropertyAccessNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.TextNode; -import au.ellie.hyui.html.ast.item.Node.ExpressionNode.VariableNode; -import au.ellie.hyui.html.ast.utils.NumericUtils; -import au.ellie.hyui.html.ast.utils.ReflectionUtils; - -import java.lang.reflect.Field; +import au.ellie.hyui.html.TemplateProcessor.CachedComponent; +import au.ellie.hyui.html.template.context.FilterRegistry; +import au.ellie.hyui.html.template.context.VariableStack; +import au.ellie.hyui.html.template.item.Node; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.StaticAttributeNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.EachBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.IfBlockNode; +import au.ellie.hyui.html.template.item.Node.ComponentElementNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.BinaryOpNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.DefaultNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.LiteralNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.PipeNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.PropertyAccessNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.TextNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.VariableNode; +import au.ellie.hyui.html.template.item.Symbols; +import au.ellie.hyui.html.template.utils.NumericUtils; +import au.ellie.hyui.html.template.utils.ReflectionUtils; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -43,13 +48,16 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Supplier; public class Evaluator { private final FilterRegistry filterRegistry; - private final VariableStackImpl contextStack; + private final VariableStack contextStack; + private Map components; - public Evaluator(Map variables, FilterRegistry filterRegistry) { - this.contextStack = new VariableStackImpl(variables); + public Evaluator(VariableStack context, FilterRegistry filterRegistry, Map components) { + this.components = components; + this.contextStack = context; this.filterRegistry = filterRegistry; } @@ -60,7 +68,7 @@ public Evaluator(Map variables, FilterRegistry filterRegistry) { * @return The resulting string after evaluation. */ public String evaluate(List nodes) { - StringBuilder result = new StringBuilder(); + var result = new StringBuilder(); for (Node node : nodes) result.append(evaluateNode(node)); @@ -78,11 +86,12 @@ private String evaluateNode(Node node) { return switch (node) { case TextNode text -> text.content(); case ExpressionNode expr -> { - Object value = evaluateExpression(expr); + var value = evaluateExpression(expr); yield value == null ? "" : value.toString(); } case IfBlockNode ifBlock -> evaluateIfBlock(ifBlock); case EachBlockNode eachBlock -> evaluateEachBlock(eachBlock); + case ComponentElementNode component -> evaluateComponent(component); default -> throw new IllegalStateException("Unexpected value: " + node); }; @@ -96,6 +105,7 @@ private String evaluateNode(Node node) { */ private Object evaluateExpression(ExpressionNode node) { return switch (node) { + case TextNode literal -> literal.content(); case LiteralNode literal -> literal.value(); case VariableNode var -> contextStack.getVariable(var.name()); case PropertyAccessNode prop -> evaluatePropertyAccess(prop); @@ -112,10 +122,10 @@ private Object evaluateExpression(ExpressionNode node) { * @return The value of the accessed property, or null if not found. */ private Object evaluatePropertyAccess(PropertyAccessNode node) { - Object obj = evaluateExpression(node.object()); + var obj = evaluateExpression(node.object()); if (obj == null) return null; - String property = node.property(); + var property = node.property(); // Access via Map if (obj instanceof Map map) @@ -123,23 +133,23 @@ private Object evaluatePropertyAccess(PropertyAccessNode node) { // Access via Reflection try { - Class clazz = obj.getClass(); + var clazz = obj.getClass(); try { - Field field = clazz.getDeclaredField(property); + var field = clazz.getDeclaredField(property); field.setAccessible(true); return field.get(obj); } catch (NoSuchFieldException e) { var propName = property.substring(0, 1).toUpperCase() + property.substring(1); - List methodNames = new ArrayList<>() {{ + var methodNames = new ArrayList() {{ add(property); add("get" + propName); add("is" + propName); }}; // Open methods - for (String name : methodNames) { + for (var name : methodNames) { var method = ReflectionUtils.getPublicMethod(clazz, name); if (method.isPresent()) @@ -160,19 +170,20 @@ private Object evaluatePropertyAccess(PropertyAccessNode node) { * @return The result of the binary operation. */ private Object evaluateBinaryOp(BinaryOpNode node) { - Object left = evaluateExpression(node.left()); - Object right = evaluateExpression(node.right()); + var left = evaluateExpression(node.left()); + var right = evaluateExpression(node.right()); return switch (node.operator()) { - case COMP_EQUALS -> evaluateEquals(left, right); - case COMP_NOT_EQUALS -> !evaluateEquals(left, right); - case COMP_LESS_THAN -> evaluateComparison(left, right) < 0; - case COMP_GREATER_THAN -> evaluateComparison(left, right) > 0; - case COMP_LESS_EQUALS -> evaluateComparison(left, right) <= 0; - case COMP_GREATER_EQUALS -> evaluateComparison(left, right) >= 0; - case COMP_AND -> toBoolean(left) && toBoolean(right); - case COMP_OR -> toBoolean(left) || toBoolean(right); - case COMP_IN -> evaluateIn(left, right); + case Symbols.EQUALS -> evaluateEquals(left, right); + case Symbols.NOT_EQUALS -> !evaluateEquals(left, right); + case Symbols.LESS_THAN -> evaluateComparison(left, right) < 0; + case Symbols.GREATER_THAN -> evaluateComparison(left, right) > 0; + case Symbols.LESS_THAN_EQUALS -> evaluateComparison(left, right) <= 0; + case Symbols.GREATER_THAN_EQUALS -> evaluateComparison(left, right) >= 0; + case Symbols.AND -> toBoolean(left) && toBoolean(right); + case Symbols.OR -> toBoolean(left) || toBoolean(right); + case Symbols.IN -> evaluateIn(left, right); + case Symbols.NOT_IN -> !evaluateIn(left, right); default -> throw new RuntimeException("Unknown operator: " + node.operator()); }; } @@ -188,8 +199,8 @@ private boolean evaluateEquals(Object left, Object right) { if (left == null && right == null) return true; if (left == null || right == null) return false; - Number leftNum = NumericUtils.toNumber(left); - Number rightNum = NumericUtils.toNumber(right); + var leftNum = NumericUtils.toNumber(left); + var rightNum = NumericUtils.toNumber(right); if (leftNum != null && rightNum != null) return NumericUtils.equals(leftNum, rightNum); @@ -204,20 +215,20 @@ private boolean evaluateEquals(Object left, Object right) { * @param right Right value of comparison * @return Negative if left < right, 0 if left == right, positive if left > right */ + @SuppressWarnings("unchecked") private int evaluateComparison(Object left, Object right) { if (left == null && right == null) return 0; if (left == null) return -1; if (right == null) return 1; - Number leftNum = NumericUtils.toNumber(left); - Number rightNum = NumericUtils.toNumber(right); + var leftNum = NumericUtils.toNumber(left); + var rightNum = NumericUtils.toNumber(right); if (leftNum != null && rightNum != null) return NumericUtils.compare(leftNum, rightNum); if (left instanceof Comparable && left.getClass().isInstance(right)) { - @SuppressWarnings("unchecked") - Comparable leftComp = (Comparable) left; + var leftComp = (Comparable) left; return leftComp.compareTo(right); } @@ -232,8 +243,8 @@ private int evaluateComparison(Object left, Object right) { * @return The result of the filter application */ private Object evaluatePipe(PipeNode node) { - Object value = evaluateExpression(node.expression()); - FilterRegistry.Filter filter = filterRegistry.get(node.filterName()); + var value = evaluateExpression(node.expression()); + var filter = filterRegistry.get(node.filterName()); return filter.apply(value); } @@ -246,7 +257,7 @@ private Object evaluatePipe(PipeNode node) { */ private Object evaluateDefault(DefaultNode node) { for (ExpressionNode alternative : node.alternatives()) { - Object value = evaluateExpression(alternative); + var value = evaluateExpression(alternative); if (value != null && !value.toString().isEmpty()) return value; } @@ -261,9 +272,9 @@ private Object evaluateDefault(DefaultNode node) { * @return The resulting string after evaluation. */ private String evaluateIfBlock(IfBlockNode node) { - Object conditionValue = evaluateExpression(node.condition()); + var conditionValue = evaluateExpression(node.condition()); - StringBuilder result = new StringBuilder(); + var result = new StringBuilder(); if (toBoolean(conditionValue)) { for (Node child : node.thenBody()) result.append(evaluateNode(child)); @@ -282,16 +293,16 @@ private String evaluateIfBlock(IfBlockNode node) { * @return The resulting string after evaluation. */ private String evaluateEachBlock(EachBlockNode node) { - Object collectionValue = evaluateExpression(node.collection()); + var collectionValue = evaluateExpression(node.collection()); if (collectionValue == null) return ""; - Iterable items = toIterable(collectionValue); - StringBuilder result = new StringBuilder(); + var items = toIterable(collectionValue); + var result = new StringBuilder(); for (Object item : items) { - Map context = new HashMap<>(); + var context = new HashMap(); context.put(node.itemName(), item); contextStack.pushScope(context); @@ -306,6 +317,45 @@ private String evaluateEachBlock(EachBlockNode node) { return result.toString(); } + /** + * Evaluate a `component` element node and return the resulting string. + * + * @param component The `component` element node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateComponent(ComponentElementNode component) { + var componentDef = component.tagName(); + var cachedComponent = components.get(componentDef); + + if (cachedComponent == null) + throw new RuntimeException("Component not found: " + componentDef); + + var context = new HashMap(); + for (var entry : component.attributes().entrySet()) { + switch (entry.getValue()) { + case DynamicAttributeNode dynamicAttributeNode -> + context.put(entry.getKey(), evaluateExpression(dynamicAttributeNode.expression())); + case StaticAttributeNode staticAttributeNode -> + context.put(entry.getKey(), staticAttributeNode.value()); + case FlagAttributeNode _ -> context.put(entry.getKey(), true); + } + } + + context.put("children", (Supplier) () -> { + var result = new StringBuilder(); + for (Node child : component.children()) + result.append(evaluateNode(child)); + return result.toString(); + }); + + contextStack.pushScope(context); + try { + return evaluate(cachedComponent.getAst(components)); + } finally { + contextStack.popScope(); + } + } + // ===== Helpers ===== /** diff --git a/src/main/java/au/ellie/hyui/html/template/Lexer.java b/src/main/java/au/ellie/hyui/html/template/Lexer.java new file mode 100644 index 0000000..9d368f9 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/Lexer.java @@ -0,0 +1,629 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template; + +import au.ellie.hyui.html.TemplateProcessor.CachedComponent; +import au.ellie.hyui.html.template.item.Token; +import au.ellie.hyui.html.template.item.Token.Type; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static au.ellie.hyui.html.template.item.Symbols.*; + +public class Lexer { + private final Map components; + private final String input; + private final String name; + + private int line = 1; + private int pos = 0; + + public Lexer(String input, Map components, String name) { + this.components = components; + this.input = input; + this.name = name; + } + + /** + * Tokenize the input string into a list of tokens + */ + public List tokenize() { + var tokens = new ArrayList(); + + while (pos < input.length()) { + if (peek(EXPRESSION_START)) + tokenizeExpressionOrBlock(tokens); + else if (isDeclaredComponent()) { + var savedPos = pos; + var executed = peek(COMPONENT_CLOSE) ? + tokenizeEndComponent(tokens) : + tokenizeStartComponent(tokens); + + if (!executed) { + pos = savedPos; + tokenizeText(tokens); + } + } else + tokenizeText(tokens); + } + + tokens.add(new Token(Type.EOF, "", pos)); + + return tokens; + } + + /** + * Tokenize expressions and blocks + * + * @param tokens The list to add tokens to + */ + private void tokenizeExpressionOrBlock(List tokens) { + expect(EXPRESSION_START); + tokens.add(new Token(Type.EXPRESSION_OPEN, EXPRESSION_START, pos - EXPRESSION_START.length())); + + skipWhitespace(); + + var consumeLine = false; + if (consume(BLOCK_START)) { + consumeLine = true; + + trimWhitespaceForBlock(tokens); + tokens.add(new Token(Type.BLOCK_HEAD, BLOCK_START, pos - BLOCK_START.length())); + tokens.add(tokenizeIdentifier(Type.IDENTIFIER)); + skipWhitespace(); + } else if (consume(BLOCK_END)) { + consumeLine = true; + + trimWhitespaceForBlock(tokens); + tokens.add(new Token(Type.BLOCK_TAIL, BLOCK_END, pos - BLOCK_END.length())); + tokens.add(tokenizeIdentifier(Type.IDENTIFIER)); + skipWhitespace(); + } + + tokenizeExpression(tokens); + + expect(EXPRESSION_END); + tokens.add(new Token(Type.EXPRESSION_CLOSE, EXPRESSION_END, pos - EXPRESSION_END.length())); + + if (consumeLine) + skipBlockLineEnd(); + } + + /** + * Tokenize an expression until the closing "}}" + * + * @param tokens The list to add tokens to + */ + private void tokenizeExpression(List tokens) { + skipWhitespace(); + + while (pos < input.length()) { + if (peek(EXPRESSION_END)) + break; + + var current = current(); + + if (peek(QUOTE)) { + tokens.add(tokenizeString()); + } else if (peek(VARIABLE)) { + tokens.add(tokenizeVariable()); + } else if (peek(DOT)) { + tokens.add(new Token(Type.VARIABLE_DOT, DOT, pos)); + skip(DOT); + } else if (isNumberType()) { + tokens.add(tokenizeNumber()); + } else { + var comparator = filter(COMPARATORS); + if (comparator != null) { + tokens.add(new Token(Type.COMPARATOR, comparator, pos)); + skip(comparator); + } else { + var operator = filter(OPERATORS); + if (operator != null) { + tokens.add(new Token(Type.OPERATOR, operator, pos)); + skip(operator); + } else if (Character.isLetter(current)) + tokens.add(tokenizeIdentifier()); + else + throwError("Unexpected character: " + current(), pos); + } + } + + skipWhitespace(); + } + } + + /** + * Tokenize a string literal + */ + private Token tokenizeString() { + var start = pos; + expect(QUOTE); + + var current = current(); + var builder = new StringBuilder(); + while (pos < input.length() && !peek(QUOTE)) { + if (current == '\\' && pos + 1 < input.length()) { + current = skip(); + + switch (current) { + case 'n' -> builder.append('\n'); + case 't' -> builder.append('\t'); + case '"' -> builder.append('"'); + case '\\' -> builder.append('\\'); + default -> builder.append(current); + } + } else + builder.append(current); + + current = skip(); + } + + expect(QUOTE); + return new Token(Type.STRING, builder.toString(), start); + } + + /** + * Tokenize a variable (starts with $) + */ + private Token tokenizeVariable() { + var start = pos; + expect(VARIABLE); + + var current = current(); + var builder = new StringBuilder(); + while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '_' || current == '-')) { + builder.append(current); + current = skip(); + } + + return new Token(Type.VARIABLE, builder.toString(), start); + } + + /** + * Tokenize a number (integer or decimal) + */ + private Token tokenizeNumber() { + var current = current(); + var builder = new StringBuilder(); + if (current == '-') { + builder.append(current()); + current = skip(); + } + + // Must have at least one digit after the sign + var start = pos; + if (!Character.isDigit(current)) { + pos = start; + + throwError("Expected digit after '-'", pos); + } + + var hasDecimal = false; + while (pos < input.length() && (Character.isDigit(current) || current == '.')) { + if (current == '.') { + if (hasDecimal) + break; + + hasDecimal = true; + } + + builder.append(current()); + current = skip(); + } + + return new Token(Type.NUMBER, builder.toString(), start); + } + + /** + * Tokenize an identifier or keyword + */ + private Token tokenizeIdentifier() { + return tokenizeIdentifier(null); + } + + /** + * Tokenize an identifier or keyword with specified type + */ + private Token tokenizeIdentifier(Type type) { + var start = pos; + + var current = current(); + var builder = new StringBuilder(); + while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '_' || current == '-')) { + builder.append(current); + current = skip(); + } + + var value = builder.toString(); + if (type == null) { + type = switch (value) { + case "true", "false" -> Type.BOOLEAN; + default -> Type.IDENTIFIER; + }; + } + + return new Token(type, value, start); + } + + // ===== Components ===== + + /** + * Tokenize plain text until the next expression or HTML tag + * + * @param tokens The list to add tokens to + */ + private void tokenizeText(List tokens) { + var start = pos; + + var builder = new StringBuilder(); + while (pos < input.length() && !peek(EXPRESSION_START) && !isDeclaredComponent()) { + builder.append(current()); + skip(); + } + + if (!builder.isEmpty()) + tokens.add(new Token(Type.TEXT, builder.toString(), start)); + } + + /** + * Tokenize an HTML start tag: "" + * + * @param tokens The list to add tokens to + */ + private boolean tokenizeStartComponent(List tokens) { + expect(COMPONENT_START); + skipWhitespace(); + + // Tag name + var identifier = tokenizeComponentName(); + if (Objects.equals(identifier.value(), this.name) || !components.containsKey(identifier.value())) + return false; + + tokens.add(new Token(Type.COMPONENT_OPEN, COMPONENT_START, identifier.position() - COMPONENT_START.length())); + tokens.add(new Token(Type.IDENTIFIER, identifier.value(), identifier.position())); + skipWhitespace(); + + // Attributes + while (pos < input.length() && !peek(COMPONENT_END, COMPONENT_SELF_CLOSE)) { + // Attribute name + if (peek("--") || Character.isLetter(current())) + tokens.add(tokenizeComponentAttributeName()); + + skipWhitespace(); + + // Check for = and value + if (consume(ASSIGN)) { + tokens.add(new Token(Type.ASSIGN, ASSIGN, pos - ASSIGN.length())); + skipWhitespace(); + + if (peek(EXPRESSION_START)) { + tokenizeExpressionOrBlock(tokens); + } else if (peek(QUOTE)) + tokens.add(tokenizeString()); + else if (isNumberType()) + tokens.add(tokenizeNumber()); + else + throwError("Unexpected character in attribute value: " + current(), pos); + } else if (peek(EXPRESSION_START)) + tokenizeExpressionOrBlock(tokens); + else + throwError("Unexpected character in attribute value: " + current(), pos); + + skipWhitespace(); + } + + // Self-closing or normal close + var close = filter(COMPONENT_END, COMPONENT_SELF_CLOSE); + if (close != null) { + tokens.add(new Token(Type.COMPONENT_CLOSE, close, pos)); + skip(close); + } else + throwError("Expected '" + COMPONENT_END + "' or '" + COMPONENT_SELF_CLOSE + "' to close tag", pos); + + return true; + } + + /** + * Tokenize an HTML end tag: + */ + private boolean tokenizeEndComponent(List tokens) { + expect(COMPONENT_CLOSE); + skipWhitespace(); + + // Tag name + var identifier = tokenizeComponentName(); + if (Objects.equals(identifier.value(), this.name) || !components.containsKey(identifier.value())) + return false; + + tokens.add(new Token(Type.COMPONENT_OPEN, COMPONENT_CLOSE, identifier.position() - COMPONENT_START.length())); + tokens.add(new Token(Type.IDENTIFIER, identifier.value(), identifier.position())); + skipWhitespace(); + + if (consume(COMPONENT_END)) + tokens.add(new Token(Type.COMPONENT_CLOSE, COMPONENT_END, pos - COMPONENT_END.length())); + else + throwError("Expected '" + COMPONENT_END + "' to close end tag", pos); + + return true; + } + + /** + * Tokenize an HTML attribute string value + */ + private Token tokenizeComponentAttributeName() { + var start = pos; + + var current = current(); + var builder = new StringBuilder(); + while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '-' || current == ':')) { + builder.append(current); + current = skip(); + } + + return new Token(Type.ATTRIBUTE, builder.toString(), start); + } + + /** + * Tokenize an HTML attribute name + */ + private Token tokenizeComponentName() { + var start = pos; + + var current = current(); + var builder = new StringBuilder(); + while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '-')) { + builder.append(current); + current = skip(); + } + + return new Token(Type.IDENTIFIER, builder.toString(), start); + } + + // ===== Helpers ===== + + /** + * Returns the current character or '\0' if at the end of input + */ + private char current() { + return pos < input.length() ? input.charAt(pos) : '\0'; + } + + /** + * Returns the next character without advancing the position + */ + private char next() { + return (pos + 1) < input.length() ? input.charAt(pos + 1) : '\0'; + } + + /** + * Peeks ahead to see if the next characters match the given string + * + * @param str The string(s) to match + */ + private boolean peek(String... str) { + for (var s : str) + if (input.startsWith(s, pos)) + return true; + + return false; + } + + /** + * Filter the next characters to see if they match any of the given strings + * + * @param str The string(s) to match + * @return The matched string, or null if none matched + */ + private String filter(String... str) { + for (var s : str) + if (input.startsWith(s, pos)) + return s; + + return null; + } + + /** + * Move the current position forward by the length of the given symbol + * + * @param str The string(s) to consume + */ + private boolean consume(String... str) { + for (var s : str) { + if (input.startsWith(s, pos)) { + skip(s); + return true; + } + } + + return false; + } + + /** + * Expect the next characters to match the given string + * + * @param str The string to expect + */ + private void expect(String str) { + expect(str, "Expected " + str + ", got '" + (pos < input.length() ? input.charAt(pos) : "EOF") + "'"); + } + + /** + * Expect the next characters to match the given string + * + * @param str The string to expect + * @param message The error message to use + */ + private void expect(String str, String message) { + if (input.startsWith(str, pos)) { + skip(str); + return; + } + + throwError(message, pos); + } + + /** + * Move the current position forward by one character + */ + private char skip() { + return skip(1); + } + + /** + * Advance the current position by the length of the given symbol + * + * @param symbol The symbol to advance by + */ + private char skip(String symbol) { + return skip(symbol.length()); + } + + /** + * Advance the current position by count characters + * + * @param count Number of characters to advance + */ + private char skip(int count) { + for (var i = 0; i < count && pos < input.length(); i++) { + if (input.charAt(pos) == '\n') + line++; + + pos++; + } + + return current(); + } + + /** + * Check if the current position starts a number + */ + private boolean isNumberType() { + var current = current(); + return Character.isDigit(current) || + (current == '-' && Character.isDigit(next())); + } + + /** + * Check if current position starts an HTML tag (not just a less-than operator) + */ + private boolean isDeclaredComponent() { + if (!peek(COMPONENT_START)) + return false; + + var savedPos = this.pos; + var declaredComponent = false; + + if (consume(COMPONENT_CLOSE) || consume(COMPONENT_START)) { + skipWhitespace(); + var identifier = tokenizeComponentName(); + if (!Objects.equals(identifier.value(), this.name) && components.containsKey(identifier.value())) + declaredComponent = true; + } + + this.pos = savedPos; + return declaredComponent; + } + + // === Whitespace === + + /** + * Skip whitespace characters + */ + private void skipWhitespace() { + while (pos < input.length() && Character.isWhitespace(current())) + skip(); + } + + /** + * Trim trailing whitespace from the last text token in a block + * if it only contains whitespace after the last newline + * + * @param tokens The list of tokens to trim + */ + private void trimWhitespaceForBlock(List tokens) { + if (tokens.size() < 2) + return; + + var last = tokens.get(tokens.size() - 2); + if (last.type() != Type.TEXT) + return; + + var text = last.value(); + int lastNewlineIndex = text.lastIndexOf('\n'); + + if (lastNewlineIndex == -1) { + if (tokens.size() == 1 && text.matches("^[ \\t]+$")) + tokens.removeFirst(); + + return; + } + + var afterLastNewline = text.substring(lastNewlineIndex + 1); + if (afterLastNewline.matches("^[ \\t]*$")) { + var keepPart = text.substring(0, lastNewlineIndex + 1); + tokens.set(tokens.size() - 2, new Token(Type.TEXT, keepPart, last.position())); + } + } + + /** + * Skip whitespace and a newline if present after a standalone tag + */ + private void skipBlockLineEnd() { + var start = pos; + + // Skip spaces and tabs + var current = current(); + while (pos < input.length() && (current == ' ' || current == '\t' || current == '\r')) + current = skip(); + + // Check for newline + if (pos < input.length() && current == '\n') + skip(); + else + pos = start; + } + + // === Errors === + + private String getLine(int lineNumber) { + var lines = input.split("\\R", -1); // handles \n, \r\n, etc. + if (lineNumber < 1 || lineNumber > lines.length) + return ""; + + return lines[lineNumber - 1]; + } + + private void throwError(String message, int errorPos) { + var arrow = " ".repeat(Math.max(0, errorPos)) + + "↳ " + message; + + String formattedMessage = String.format(""" + An error occurred when parsing the input at line %d, column %d + %s + %s + """, line, errorPos, getLine(line), arrow + ); + + throw new RuntimeException(formattedMessage); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/Parser.java b/src/main/java/au/ellie/hyui/html/template/Parser.java new file mode 100644 index 0000000..793ce19 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/Parser.java @@ -0,0 +1,526 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template; + +import au.ellie.hyui.html.TemplateProcessor.CachedComponent; +import au.ellie.hyui.html.template.item.Node; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.StaticAttributeNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.EachBlockNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.IfBlockNode; +import au.ellie.hyui.html.template.item.Node.ComponentElementNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.BinaryOpNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.DefaultNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.LiteralNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.PipeNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.PropertyAccessNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.TextNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.VariableNode; +import au.ellie.hyui.html.template.item.Symbols; +import au.ellie.hyui.html.template.item.Token; +import au.ellie.hyui.html.template.item.Token.Type; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Stack; + +import static au.ellie.hyui.html.template.item.Token.Type.*; + +public class Parser { + private final Map components; + private final Stack context; + private final List tokens; + private int pos = 0; + + public Parser(List tokens, Map componentCache) { + this.components = componentCache; + this.context = new Stack<>(); + this.tokens = tokens; + } + + /** + * Parse the list of tokens into an AST + * + * @return List of AST nodes + */ + public List parse() { + List nodes = new ArrayList<>(); + + while (!isAtEnd()) { + var node = parseNode(); + if (node != null) + nodes.add(node); + } + + return nodes; + } + + /** + * Parse a single AST node + * + * @return AST node + */ + private Node parseNode() { + var token = current(); + + return switch (token.type()) { + case TEXT -> parseText(); + case EXPRESSION_OPEN -> parseExpressionOrBlock(); + case COMPONENT_OPEN -> parseComponentElement(); + case ATTRIBUTE -> parseAttribute(); + default -> throw new RuntimeException("Unexpected token: " + token); + }; + } + + // ===== Primary Expression ===== + + /** + * Parse a text that represents value we ignore here + * + * @return TextNode + */ + private TextNode parseText() { + return new TextNode(expect(TEXT).value()); + } + + /** + * Parse primary expressions (literals, variables, property access) + * + * @return Expression node + */ + private ExpressionNode parsePrimary() { + // String literal + if (consume(STRING)) + return new LiteralNode(previous().value()); + + // Number literal + if (consume(NUMBER)) { + var value = previous().value(); + + if (value.contains(".")) + return new LiteralNode(Double.parseDouble(value)); + else + return new LiteralNode(Long.parseLong(value)); + } + + // Boolean literal + if (consume(BOOLEAN)) + return new LiteralNode(Boolean.parseBoolean(previous().value())); + + // Variable with property access + if (consume(VARIABLE)) { + var name = previous().value(); + + ExpressionNode expr = new VariableNode(name); + while (consume(VARIABLE_DOT)) { + var property = expect(IDENTIFIER).value(); + expr = new PropertyAccessNode(expr, property); + } + + return expr; + } + + throw new RuntimeException("Unexpected token in expression: " + current()); + } + + // ===== Logical Expression ===== + + /** + * Parse logical `OR` expressions + * + * @return Expression node + */ + private ExpressionNode parseOr() { + var left = parseAnd(); + + while (consume(OPERATOR, Symbols.OR)) { + var right = parseAnd(); + left = new BinaryOpNode(left, Symbols.OR, right); + } + + return left; + } + + /** + * Parse logical `AND` expressions + * + * @return Expression node + */ + private ExpressionNode parseAnd() { + var left = parseComparison(); + + while (consume(OPERATOR, Symbols.AND)) { + var right = parseComparison(); + left = new BinaryOpNode(left, Symbols.AND, right); + } + + return left; + } + + /** + * Parse `comparison` expressions + * + * @return Expression node + */ + private ExpressionNode parseComparison() { + var left = parsePipe(); + + var operation = get(COMPARATOR, Symbols.COMPARATORS); + if (operation != null) { + var right = parsePipe(); + return new BinaryOpNode(left, operation.value(), right); + } + + return left; + } + + /** + * Parse `pipe` expressions + * + * @return Expression node + */ + private ExpressionNode parsePipe() { + var expr = parsePrimary(); + + while (consume(OPERATOR, Symbols.PIPE)) { + var name = expect(IDENTIFIER).value(); + expr = new PipeNode(expr, name); + } + + return expr; + } + + /** + * Parse `nullish` coalescing expressions + * + * @return Expression node + */ + private ExpressionNode parseNullish() { + var alternatives = new ArrayList(); + + do { + alternatives.add(parseOr()); + } while (consume(OPERATOR, Symbols.NULL_COALESCING)); + + return alternatives.size() == 1 ? alternatives.getFirst() : new DefaultNode(alternatives); + } + + // ===== Expression and Block ===== + + /** + * Parse either an expression or a block + * + * @return AST node representing the expression or block + */ + private Node parseExpressionOrBlock() { + expect(EXPRESSION_OPEN); + Node node; + + if (consume(BLOCK_HEAD)) + node = parseBlock(); + else + node = parseExpression(); + + expect(EXPRESSION_CLOSE); + return node; + } + + /** + * Parse an expression + * + * @return AST node representing the expression + */ + private ExpressionNode parseExpression() { + return parseNullish(); + } + + /** + * Parse a block (if, each, etc.) + * + * @return AST node representing the block + */ + private Node parseBlock() { + var token = current(); + + return switch (token.value()) { + case Symbols.SECTION_IF -> parseIfBlock(); + case Symbols.SECTION_EACH -> parseEachBlock(); + default -> + throw new RuntimeException("Unknown block value \"" + token.value() + "\" for token " + token.type()); + }; + } + + /** + * Parse an `if` block + *
    {@code
    +     * {{#if condition}}
    +     *   ...
    +     * {{else}}
    +     *   ...
    +     * {{/if}}
    +     * }
    + */ + private IfBlockNode parseIfBlock() { + expect(IDENTIFIER, Symbols.SECTION_IF); + var condition = parseExpression(); + expect(EXPRESSION_CLOSE); + + var thenBody = new ArrayList(); + while (!(peek(EXPRESSION_OPEN) && (next().match(BLOCK_TAIL) || next().match(IDENTIFIER, Symbols.SECTION_ELSE)))) + thenBody.add(parseNode()); + + expect(EXPRESSION_OPEN); + + var elseBody = new ArrayList(); + if (consume(IDENTIFIER, Symbols.SECTION_ELSE)) { + expect(EXPRESSION_CLOSE); + + while (!(peek(EXPRESSION_OPEN) && next().match(BLOCK_TAIL))) + elseBody.add(parseNode()); + + expect(EXPRESSION_OPEN); + } + + expect(BLOCK_TAIL); + expect(IDENTIFIER, Symbols.SECTION_IF); + return new IfBlockNode(condition, thenBody, elseBody); + } + + /** + * Parse an `each` block. + *
    {@code
    +     * {{#each $collection }}
    +     *   ...
    +     * {{/each}}
    +     * }
    + */ + private EachBlockNode parseEachBlock() { + expect(IDENTIFIER, Symbols.SECTION_EACH); + var collection = parseExpression(); + + var itemName = "item"; + if (peek(IDENTIFIER)) + itemName = expect(IDENTIFIER).value(); + + expect(EXPRESSION_CLOSE); + + var body = new ArrayList(); + while (!(peek(EXPRESSION_OPEN) && next().match(BLOCK_TAIL))) + body.add(parseNode()); + + expect(EXPRESSION_OPEN); + expect(BLOCK_TAIL); + expect(IDENTIFIER, Symbols.SECTION_EACH); + return new EachBlockNode(itemName, collection, body); + } + + // ===== Component ===== + + /** + * Parse an component element + */ + private ComponentElementNode parseComponentElement() { + expect(COMPONENT_OPEN, Symbols.COMPONENT_START); + var identifier = expect(IDENTIFIER); + context.push(identifier); + + // Parse attributes + var attributes = new LinkedHashMap(); + var blockAttributes = new ArrayList(); + + try { + while (!peek(COMPONENT_CLOSE) && !isAtEnd()) { + var attribute = parseAttribute(); + if (attribute instanceof AttributeValueNode attrNode) + attributes.put(attrNode.getName(), attrNode); + else + blockAttributes.add(attribute); + } + } finally { + context.pop(); + } + + // Check for self-closing or regular close + var selfClosing = expect(COMPONENT_CLOSE).match(Symbols.COMPONENT_SELF_CLOSE); + var children = selfClosing ? new ArrayList() : parseHtmlChildren(identifier.value()); + + return new ComponentElementNode(identifier.value(), attributes, blockAttributes, children); + } + + /** + * Parse an HTML attribute + */ + + private Node parseAttribute() { + var context = this.context.peek(); + if (context == null) + throw new RuntimeException("No HTML tag context for attribute at position " + pos); + + var attribute = get(ATTRIBUTE); + if (attribute != null) { + var name = attribute.value(); + if (!peek(ASSIGN)) + return new FlagAttributeNode(name); + + expect(ASSIGN); + + var token = get(STRING); + if (token != null) { + return new StaticAttributeNode(name, token.value()); + } else if (consume(EXPRESSION_OPEN)) { + var expr = parseExpression(); + expect(EXPRESSION_CLOSE); + + return new DynamicAttributeNode(name, expr); + } + + throw new RuntimeException("Expected attribute value at position " + current().position()); + } else if (peek(EXPRESSION_OPEN)) + return parseExpressionOrBlock(); + + throw new RuntimeException("Unexpected token in tag <" + context.value() + ">: " + current()); + } + + /** + * Parse the children of an HTML element until the closing tag + */ + private List parseHtmlChildren(String parentTag) { + var children = new ArrayList(); + + while (!isAtEnd()) { + var savedPos = pos; + + // Detect closing tag + if (consume(COMPONENT_OPEN, Symbols.COMPONENT_CLOSE) && consume(IDENTIFIER, parentTag)) { + expect(COMPONENT_CLOSE); + return children; + } else + pos = savedPos; + + // Parse children + Node child = parseNode(); + if (child != null) + children.add(child); + } + + throw new RuntimeException("Unclosed tag: " + parentTag + " at position " + tokens.getLast().position()); + } + + // ===== Helpers ===== + + /** + * Get the current token + */ + private Token current() { + return tokens.get(pos); + } + + /** + * Get the previous token + */ + private Token previous() { + return tokens.get(pos - 1); + } + + /** + * Get the next token + */ + private Token next() { + return tokens.get(pos + 1); + } + + /** + * Consume the current token and return it + */ + private Token skip() { + if (!isAtEnd()) pos++; + return current(); + } + + /** + * If the current token matches the given type and value, return true. + * Otherwise, return false. + * + * @param type The token type to check + * @param values The token values to check + */ + private boolean peek(Type type, String... values) { + return current().match(type, values); + } + + /** + * If the current token matches the given type, consume it and return true. + * Otherwise, return false. + * + * @param type The token type to check + * @param values The token values to check + */ + private boolean consume(Type type, String... values) { + if (current().match(type, values)) { + skip(); + return true; + } + + return false; + } + + /** + * If the current token matches any of the values in the given types, consume it and return the value. + * Otherwise, return null. + * + * @param type The token type to check + * @param values The token values to check + */ + private Token get(Type type, String... values) { + var token = current(); + if (token.match(type, values)) { + skip(); + return token; + } + + return null; + } + + /** + * Except the token to match the given type and any of the given values, consuming it. + * + * @throws RuntimeException if the expected type or value do not match + */ + private Token expect(Type type, String... values) { + var token = current(); + if (token.match(type, values)) { + skip(); + return token; + } + + throw new RuntimeException("Expected " + type + (values.length > 0 ? " with value \"" + String.join("/", values) + "\"" : "") + " but got " + current().type() + " with value " + current().value() + " at index " + pos); + } + + /** + * Check if we have reached the end of the token list + */ + private boolean isAtEnd() { + return current().match(EOF); + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java b/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java similarity index 98% rename from src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java rename to src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java index 33c8f24..e957b2e 100644 --- a/src/main/java/au/ellie/hyui/html/ast/context/FilterRegistry.java +++ b/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.ast.context; +package au.ellie.hyui.html.template.context; import java.util.Collection; import java.util.HashMap; diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java new file mode 100644 index 0000000..2bb12d0 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.TemplateProcessor.ValueResolver; + +import javax.annotation.Nullable; +import java.util.ArrayDeque; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +public class VariableStack { + public static final Object NULL_SENTINEL = new Object(); + + private final ArrayDeque> stack = new ArrayDeque<>(); + private final ValueResolver valueResolver; + private final boolean preferDynamicValues; + + public VariableStack(Map globalScope, @Nullable ValueResolver valueResolver, boolean preferDynamicValues) { + this.valueResolver = valueResolver; + this.preferDynamicValues = preferDynamicValues; + + pushScope(globalScope); + } + + public void pushScope(Map scope) { + stack.push(scope); + } + + public void popScope() { + if (stack.size() > 1) + stack.pop(); + else + throw new IllegalStateException("Cannot pop the global scope"); + } + + /** + * Retrieve a variable from the stack. + * + * @param name Variable name + * @return Variable value, or null if not found + */ + public Object getVariable(String name) { + return getVariable(name, null); + } + + /** + * Retrieve a variable from the stack, with a default value. + * + * @param name Variable name + * @param defaultValue Default value if variable not found + * @return Variable value, or defaultValue if not found + */ + public Object getVariable(String name, Object defaultValue) { + // Check dynamic values first if preferred + if (preferDynamicValues && valueResolver != null) { + Optional resolved = valueResolver.resolve(name); + if (resolved.isPresent() && resolved.get() != NULL_SENTINEL) + return resolved; + } + + for (Map scope : stack) { + if (scope.containsKey(name)) { + var object = scope.get(name); + + switch (object) { + case Supplier supplier -> { + object = supplier.get(); + scope.put(name, object); + return object; + } + case Function function -> { + @SuppressWarnings("unchecked") + Function stackFunction = + (Function) function; + + return stackFunction.apply(this); + } + case null -> { + return null; + } + default -> { + return object; + } + } + } + } + + // Check dynamic values last if not preferred + if (valueResolver != null) { + Optional resolved = valueResolver.resolve(name); + if (resolved.isPresent()) { + Object value = resolved.get(); + return value == NULL_SENTINEL ? null : value; + } + } + + return defaultValue; + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/item/Node.java b/src/main/java/au/ellie/hyui/html/template/item/Node.java similarity index 67% rename from src/main/java/au/ellie/hyui/html/ast/item/Node.java rename to src/main/java/au/ellie/hyui/html/template/item/Node.java index 4aa716b..a500f6b 100644 --- a/src/main/java/au/ellie/hyui/html/ast/item/Node.java +++ b/src/main/java/au/ellie/hyui/html/template/item/Node.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.ast.item; +package au.ellie.hyui.html.template.item; import java.util.List; import java.util.Map; @@ -29,7 +29,7 @@ sealed interface ExpressionNode extends Node { /** * Represents plain text in the template */ - record TextNode(String content) implements Node { + record TextNode(String content) implements ExpressionNode { } /** @@ -53,7 +53,7 @@ record PropertyAccessNode(ExpressionNode object, String property) implements Exp /** * Represents a binary operation between two expressions */ - record BinaryOpNode(ExpressionNode left, Token.Type operator, ExpressionNode right) implements ExpressionNode { + record BinaryOpNode(ExpressionNode left, String operator, ExpressionNode right) implements ExpressionNode { } /** @@ -67,7 +67,6 @@ record PipeNode(ExpressionNode expression, String filterName) implements Express */ record DefaultNode(List alternatives) implements ExpressionNode { } - } // ---- Control Flow Nodes ---- @@ -86,28 +85,43 @@ record EachBlockNode(String itemName, ExpressionNode collection, List body } } - // ---- Attribute Value Nodes ---- + // ---- Component Nodes ---- + + sealed interface AttributeValueNode extends Node { + String getName(); - sealed interface AttributeValue { - record Static(String value) implements AttributeValue { + record StaticAttributeNode(String name, String value) implements AttributeValueNode { + public String getName() { + return name; + } } - record Dynamic(ExpressionNode expression) implements AttributeValue { + record DynamicAttributeNode(String name, ExpressionNode expression) implements AttributeValueNode { + public String getName() { + return name; + } } - record Flag() implements AttributeValue { + record FlagAttributeNode(String name) implements AttributeValueNode { + public String getName() { + return name; + } } } - // ---- HTML Nodes ---- - - record HtmlElementNode( + record ComponentElementNode( String tagName, - Map attributes, - Map customAttributes, - List children, - boolean selfClosing + Map attributes, + List expressionAttributes, + List children ) implements Node { + public boolean hasAttribute(String name) { + return attributes.containsKey(name); + } + + public AttributeValueNode getAttribute(String name) { + return attributes.get(name); + } } } diff --git a/src/main/java/au/ellie/hyui/html/template/item/Symbols.java b/src/main/java/au/ellie/hyui/html/template/item/Symbols.java new file mode 100644 index 0000000..9b603d2 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/item/Symbols.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.item; + +public class Symbols { + public final static String VARIABLE = "$"; + public final static String DOT = "."; + public final static String QUOTE = "\""; + public final static String ASSIGN = "="; + + public final static String EXPRESSION_START = "{{"; + public final static String EXPRESSION_END = "}}"; + public final static String BLOCK_START = "#"; + public final static String BLOCK_END = "/"; + + public final static String COMPONENT_START = "<"; + public final static String COMPONENT_END = ">"; + public final static String COMPONENT_SELF_CLOSE = "/>"; + public final static String COMPONENT_CLOSE = ""; + public final static String LESS_THAN_EQUALS = "<="; + public final static String GREATER_THAN_EQUALS = ">="; + public final static String NULL_COALESCING = "??"; + public final static String NOT_IN = "not in"; + public final static String IN = "in"; + public final static String AND = "&&"; + public final static String OR = "||"; + + public final static String SECTION_IF = "if"; + public final static String SECTION_ELSE = "else"; + public final static String SECTION_EACH = "each"; + + // List of all comparators + public final static String[] COMPARATORS = new String[]{ + EQUALS, + NOT_EQUALS, + GREATER_THAN_EQUALS, + GREATER_THAN, + LESS_THAN_EQUALS, + LESS_THAN, + NOT_IN, + IN + }; + + // List of all operators + public final static String[] OPERATORS = new String[]{ + NULL_COALESCING, + OR, + AND, + PIPE + }; +} diff --git a/src/main/java/au/ellie/hyui/html/template/item/Token.java b/src/main/java/au/ellie/hyui/html/template/item/Token.java new file mode 100644 index 0000000..930bee9 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/item/Token.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.item; + +public record Token(Type type, String value, int position) { + /** + * Check if the token matches the given type and value + * + * @param type The type to check + * @param symbols The values to check + */ + public boolean match(Type type, String... symbols) { + if (this.type != type) + return false; + + if (symbols.length == 0) + return true; + + return this.match(symbols); + } + + /** + * Check if the token matches the given type and value + * + * @param symbols The values to check + */ + public boolean match(String... symbols) { + for (String v : symbols) { + if (this.value.equals(v)) + return true; + } + + return false; + } + + /** + * Token types + */ + public enum Type { + // Global + TEXT, + VARIABLE, + VARIABLE_DOT, + STRING, + NUMBER, + BOOLEAN, + IDENTIFIER, + ATTRIBUTE, + COMPARATOR, + OPERATOR, + ASSIGN, + + // Expression + EXPRESSION_OPEN, + EXPRESSION_CLOSE, + + // Components + COMPONENT_OPEN, + COMPONENT_CLOSE, + + // Block + BLOCK_HEAD, + BLOCK_TAIL, + + // Special + EOF + } +} diff --git a/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java b/src/main/java/au/ellie/hyui/html/template/utils/NumericUtils.java similarity index 63% rename from src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java rename to src/main/java/au/ellie/hyui/html/template/utils/NumericUtils.java index 1ff7efa..979ea96 100644 --- a/src/main/java/au/ellie/hyui/html/ast/utils/NumericUtils.java +++ b/src/main/java/au/ellie/hyui/html/template/utils/NumericUtils.java @@ -16,15 +16,23 @@ * */ -package au.ellie.hyui.html.ast.utils; +package au.ellie.hyui.html.template.utils; import javax.annotation.Nullable; public class NumericUtils { + + // Epsilon value for comparing floating-point numbers thanks of how number + // are represented in computers, two floating-point numbers that are very close + // may not be exactly equal due to precision issues. private static final double EPSILON = 1e-9; /** - * Convertit une valeur en Number si possible + * Convert an object to a Number if possible. + * Supports Number and String types. + * + * @param value The object to convert + * @return Number if conversion is successful, or null if it cannot be converted */ public static Number toNumber(@Nullable Object value) { switch (value) { @@ -50,7 +58,9 @@ public static Number toNumber(@Nullable Object value) { } /** - * Convertit un Number en double + * Convert a number to double. + * + * @return 0.0 if num is null */ public static double toDouble(Number num) { if (num == null) @@ -60,7 +70,9 @@ public static double toDouble(Number num) { } /** - * Convertit un Number en long + * Convert a number to long. + * + * @return 0.0 if num is null */ public static long toLong(Number num) { if (num == null) @@ -70,56 +82,50 @@ public static long toLong(Number num) { } /** - * Compare deux nombres avec epsilon pour les doubles + * Compare two objects as numbers. + * Supports Number and String types. + * + * @return A negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater than the second. */ public static int compare(Object left, Object right) { - Number leftNum = toNumber(left); - Number rightNum = toNumber(right); + var leftNum = toNumber(left); + var rightNum = toNumber(right); if (leftNum == null && rightNum == null) return 0; if (leftNum == null) return -1; if (rightNum == null) return 1; - // Si au moins un des deux est un double/float, on compare en double avec epsilon - if (isFloatingPoint(leftNum) || isFloatingPoint(rightNum)) { + // if at least one of the two is a floating-point type, compare with epsilon + if (isFloatingPoint(leftNum) || isFloatingPoint(rightNum)) return compareWithEpsilon(toDouble(leftNum), toDouble(rightNum)); - } - // Sinon, comparaison en long + // otherwise, compare as long return Long.compare(toLong(leftNum), toLong(rightNum)); } /** - * Vérifie l'égalité entre deux nombres avec epsilon pour les doubles + * Check if two objects are numerically equal. + * + * @return true if both are null or if they are numerically equal (considering epsilon for floating-point), false otherwise */ public static boolean equals(Object left, Object right) { - Number leftNum = toNumber(left); - Number rightNum = toNumber(right); - - if (leftNum == null && rightNum == null) return true; - if (leftNum == null || rightNum == null) return false; - - // Si au moins un des deux est un double/float, on compare avec epsilon - if (isFloatingPoint(leftNum) || isFloatingPoint(rightNum)) { - return Math.abs(toDouble(leftNum) - toDouble(rightNum)) < EPSILON; - } - - // Sinon, comparaison exacte en long - return toLong(leftNum) == toLong(rightNum); + return compare(left, right) == 0; } /** - * Vérifie si un Number est un type à virgule flottante + * Check if a number is a floating-point type (Double or Float). */ private static boolean isFloatingPoint(Number num) { return num instanceof Double || num instanceof Float; } /** - * Compare deux doubles avec epsilon + * Compare two doubles with an epsilon tolerance. */ private static int compareWithEpsilon(double a, double b) { - if (Math.abs(a - b) < EPSILON) return 0; + if (Math.abs(a - b) < EPSILON) + return 0; + return Double.compare(a, b); } } \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java b/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java similarity index 98% rename from src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java rename to src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java index a5e7896..f53ae99 100644 --- a/src/main/java/au/ellie/hyui/html/ast/utils/ReflectionUtils.java +++ b/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.ast.utils; +package au.ellie.hyui.html.template.utils; import java.lang.reflect.Method; import java.lang.reflect.Modifier; diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index f622735..e9742e5 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -58,13 +58,13 @@ record Address(String city, String country) { record Person(String name, int age, Address address) { } - record User(String name) { + record User(String name, String lastName) { } record Category(String name, List items) { } - record Item(String name, boolean active, String display) { + record Item(String name, boolean active, String display, int count) { } record Box(int size) { @@ -83,20 +83,23 @@ class BasicProcessing { @DisplayName("Should return plain text unchanged") void plainText() { String template = "
    Hello World
    "; - assertEquals("
    Hello World
    ", processor.process(template)); + assertEquals("
    Hello World
    ", processor.setTemplate(template).process()); } @Test @DisplayName("Should replace simple variables") void simpleVariable() { processor.setVariable("name", "John"); - assertEquals("Hello John!", processor.process("Hello {{$name}}!")); + + processor.setTemplate("Hello {{$name}}!"); + assertEquals("Hello John!", processor.process()); } @Test @DisplayName("Should handle missing variables as empty strings") void missingVariable() { - assertEquals("Hello !", processor.process("Hello {{$name}}!")); + processor.setTemplate("Hello {{$name}}!"); + assertEquals("Hello !", processor.process()); } @Test @@ -104,13 +107,16 @@ void missingVariable() { void variableNaming() { processor.setVariable("my-var", "value1"); processor.setVariable("my_var", "value2"); - assertEquals("value1 value2", processor.process("{{$my-var}} {{$my_var}}")); + + processor.setTemplate("{{$my-var}} {{$my_var}}"); + assertEquals("value1 value2", processor.process()); } @Test @DisplayName("Should preserve intentional whitespace in HTML") void whitespacePreservation() { - assertEquals("
    Hello
    ", processor.process("
    Hello
    ")); + processor.setTemplate("
    Hello
    "); + assertEquals("
    Hello
    ", processor.process()); } } @@ -123,22 +129,29 @@ class Literals { @Test @DisplayName("Should handle string literals with escaping") void stringLiterals() { - assertEquals("Hello World", processor.process("{{\"Hello World\"}}")); - assertEquals("Hello \"World\"", processor.process("{{\"Hello \\\"World\\\"\"}}")); + processor.setTemplate("{{\"Hello World\"}}"); + assertEquals("Hello World", processor.process()); + + processor.setTemplate("{{\"Hello \\\"World\\\"\"}}"); + assertEquals("Hello \"World\"", processor.process()); } @ParameterizedTest @CsvSource({"{{42}}", "{{3.14}}", "{{-5}}"}) @DisplayName("Should handle numeric literals") void numericLiterals(String template) { - assertNotNull(processor.process(template)); - assertFalse(processor.process(template).isBlank()); + processor.setTemplate(template); + var result = processor.process(); + + assertNotNull(result); + assertFalse(result.isBlank()); } @Test @DisplayName("Should handle boolean literals") void booleanLiterals() { - assertEquals("true false", processor.process("{{true}} {{false}}")); + processor.setTemplate("{{true}} {{false}}"); + assertEquals("true false", processor.process()); } } @@ -152,28 +165,36 @@ class PropertyAccess { @DisplayName("Should access record properties") void recordProperties() { processor.setVariable("user", new Person("Alice", 30, null)); - assertEquals("Alice is 30", processor.process("{{$user.name}} is {{$user.age}}")); + + processor.setTemplate("{{$user.name}} is {{$user.age}}"); + assertEquals("Alice is 30", processor.process()); } @Test @DisplayName("Should access map properties") void mapProperties() { processor.setVariable("user", Map.of("name", "Bob", "age", 25)); - assertEquals("Bob is 25", processor.process("{{$user.name}} is {{$user.age}}")); + + processor.setTemplate("{{$user.name}} is {{$user.age}}"); + assertEquals("Bob is 25", processor.process()); } @Test @DisplayName("Should access nested properties") void nestedProperties() { processor.setVariable("user", new Person("Charlie", 21, new Address("Paris", "France"))); - assertEquals("Paris, France", processor.process("{{$user.address.city}}, {{$user.address.country}}")); + + processor.setTemplate("{{$user.address.city}}, {{$user.address.country}}"); + assertEquals("Paris, France", processor.process()); } @Test @DisplayName("Should return empty string for missing properties") void missingProperties() { processor.setVariable("user", new Person("Dave", 32, null)); - assertEquals("", processor.process("{{$user.id}}")); + + processor.setTemplate("{{$user.id}}"); + assertEquals("", processor.process()); } @ParameterizedTest @@ -185,20 +206,18 @@ void missingProperties() { void supplierEvaluationOnIfCondition(boolean condition, int value, String expected) { AtomicInteger evaluations = new AtomicInteger(); - processor - .setVariable("enabled", condition) - .setVariable("secret", () -> { - evaluations.incrementAndGet(); - return "value_" + evaluations; - }); + processor.setVariable("enabled", condition); + processor.setVariable("secret", () -> { + evaluations.incrementAndGet(); + return "value_" + evaluations; + }); - String template = """ + processor.setTemplate(""" {{#if $enabled}} {{$secret}} - {{$secret}} {{/if}} - """; - - assertEquals(expected != null ? expected : "", processor.process(template).trim()); + """); + assertEquals(expected != null ? expected : "", processor.process()); assertEquals(value, evaluations.get()); } @@ -211,20 +230,18 @@ void supplierEvaluationOnIfCondition(boolean condition, int value, String expect void functionEvaluationOnIfCondition(boolean condition, int value, String expected) { AtomicInteger evaluations = new AtomicInteger(); - processor - .setVariable("enabled", condition) - .setVariable("secret", (_) -> { - evaluations.incrementAndGet(); - return "value_" + evaluations; - }); + processor.setVariable("enabled", condition); + processor.setVariable("secret", (_) -> { + evaluations.incrementAndGet(); + return "value_" + evaluations; + }); - String template = """ + processor.setTemplate(""" {{#if $enabled}} {{$secret}} - {{$secret}} {{/if}} - """; - - assertEquals(expected != null ? expected : "", processor.process(template).trim()); + """); + assertEquals(expected != null ? expected : "", processor.process()); assertEquals(value, evaluations.get()); } } @@ -248,15 +265,21 @@ class ComparisonOperators { void comparisonOperators(int left, String op, int right, boolean expected) { processor.setVariable("a", left); processor.setVariable("b", right); - assertEquals(String.valueOf(expected), processor.process("{{$a " + op + " $b}}")); + + processor.setTemplate("{{$a " + op + " $b}}"); + assertEquals(String.valueOf(expected), processor.process()); } @Test @DisplayName("Should compare strings") void stringComparison() { processor.setVariable("name", "Alice"); - assertEquals("true", processor.process("{{$name == \"Alice\"}}")); - assertEquals("false", processor.process("{{$name == \"Bob\"}}")); + + processor.setTemplate("{{$name == \"Alice\"}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{$name == \"Bob\"}}"); + assertEquals("false", processor.process()); } @Test @@ -264,8 +287,12 @@ void stringComparison() { void numericTypeMixing() { processor.setVariable("a", 5); processor.setVariable("b", 5.0); - assertEquals("true", processor.process("{{$a == $b}}")); - assertEquals("false", processor.process("{{$a != $b}}")); + + processor.setTemplate("{{$a == $b}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{$a != $b}}"); + assertEquals("false", processor.process()); } @Test @@ -273,13 +300,16 @@ void numericTypeMixing() { void floatingPointEpsilon() { processor.setVariable("a", 0.1 + 0.2); processor.setVariable("b", 0.3); - assertEquals("true", processor.process("{{$a == $b}}")); + + processor.setTemplate("{{$a == $b}}"); + assertEquals("true", processor.process()); } @Test @DisplayName("Should handle null comparisons") void nullComparison() { - assertEquals("true", processor.process("{{$value == $missing}}")); + processor.setTemplate("{{$value == $missing}}"); + assertEquals("true", processor.process()); } } @@ -296,9 +326,14 @@ void andOperator() { processor.setVariable("b", true); processor.setVariable("c", false); - assertEquals("true", processor.process("{{$a && $b}}")); - assertEquals("false", processor.process("{{$a && $c}}")); - assertEquals("false", processor.process("{{$c && $c}}")); + processor.setTemplate("{{$a && $b}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{$a && $c}}"); + assertEquals("false", processor.process()); + + processor.setTemplate("{{$c && $c}}"); + assertEquals("false", processor.process()); } @Test @@ -307,8 +342,11 @@ void orOperator() { processor.setVariable("a", true); processor.setVariable("b", false); - assertEquals("true", processor.process("{{$a || $b}}")); - assertEquals("false", processor.process("{{$b || $b}}")); + processor.setTemplate("{{$a || $b}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{$b || $b}}"); + assertEquals("false", processor.process()); } @Test @@ -318,7 +356,8 @@ void combinedLogicalOperators() { processor.setVariable("b", false); processor.setVariable("c", true); - assertEquals("true", processor.process("{{$a && $b || $c}}")); + processor.setTemplate("{{$a && $b || $c}}"); + assertEquals("true", processor.process()); } @ParameterizedTest @@ -330,16 +369,15 @@ void combinedLogicalOperators() { }) @DisplayName("Should evaluate truthiness correctly") void truthiness(String value, boolean isTruthy) { - if (value.isEmpty()) { + if (value.isEmpty()) processor.setVariable("val", ""); - } else if (value.matches("\\d+")) { + else if (value.matches("\\d+")) processor.setVariable("val", Integer.parseInt(value)); - } else { + else processor.setVariable("val", value); - } - String expected = isTruthy ? "true" : ""; - assertEquals(expected, processor.process("{{#if $val}}true{{/if}}")); + processor.setTemplate("{{#if $val}}true{{/if}}"); + assertEquals(isTruthy ? "true" : "", processor.process()); } } @@ -353,31 +391,43 @@ class InOperator { @DisplayName("Should check presence in list") void listContains() { processor.setVariable("items", List.of("apple", "banana", "cherry")); - assertEquals("true", processor.process("{{\"apple\" in $items}}")); - assertEquals("false", processor.process("{{\"orange\" in $items}}")); + + processor.setTemplate("{{\"apple\" in $items}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{\"orange\" in $items}}"); + assertEquals("false", processor.process()); } @Test @DisplayName("Should check key presence in map") void mapContainsKey() { processor.setVariable("user", Map.of("name", "Alice", "age", 30)); - assertEquals("true", processor.process("{{\"name\" in $user}}")); - assertEquals("false", processor.process("{{\"email\" in $user}}")); + + processor.setTemplate("{{\"name\" in $user}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{\"email\" in $user}}"); + assertEquals("false", processor.process()); } @Test @DisplayName("Should check substring in string") void stringContains() { processor.setVariable("text", "Hello World"); - assertEquals("true", processor.process("{{\"World\" in $text}}")); - assertEquals("false", processor.process("{{\"Java\" in $text}}")); + + processor.setTemplate("{{\"World\" in $text}}"); + assertEquals("true", processor.process()); + + processor.setTemplate("{{\"Java\" in $text}}"); + assertEquals("false", processor.process()); } } // ========== FILTERS ========== @Nested - @DisplayName("Filters") + @DisplayName("Filter transformations") class Filters { @ParameterizedTest @@ -390,14 +440,18 @@ class Filters { @DisplayName("Should apply built-in filters") void builtInFilters(String input, String filter, String expected) { processor.setVariable("value", input); - assertEquals(expected, processor.process("{{$value | " + filter + "}}")); + + processor.setTemplate("{{$value | " + filter + "}}"); + assertEquals(expected, processor.process()); } @Test @DisplayName("Should chain multiple filters") void chainedFilters() { processor.setVariable("name", " john doe "); - assertEquals("JOHN DOE", processor.process("{{$name | trim | uppercase}}")); + + processor.setTemplate("{{$name | trim | uppercase}}"); + assertEquals("JOHN DOE", processor.process()); } @Test @@ -406,9 +460,10 @@ void customFilter() { processor.registerFilter("reverse", value -> value == null ? null : new StringBuilder(value.toString()).reverse().toString() ); - processor.setVariable("text", "Hello"); - assertEquals("olleH", processor.process("{{$text | reverse}}")); + + processor.setTemplate("{{$text | reverse}}"); + assertEquals("olleH", processor.process()); } @Test @@ -417,8 +472,11 @@ void lengthFilter() { processor.setVariable("text", "Hello"); processor.setVariable("items", List.of("a", "b", "c")); - assertEquals("5", processor.process("{{$text | length}}")); - assertEquals("3", processor.process("{{$items | length}}")); + processor.setTemplate("{{$text | length}}"); + assertEquals("5", processor.process()); + + processor.setTemplate("{{$items | length}}"); + assertEquals("3", processor.process()); } @Test @@ -426,10 +484,8 @@ void lengthFilter() { void number_formatsNumber() { processor.setVariable("value", 1234); - assertEquals( - "1,234", - processor.process("{{$value | number}}") - ); + processor.setTemplate("{{$value | number}}"); + assertEquals("1,234", processor.process()); } @Test @@ -437,10 +493,8 @@ void number_formatsNumber() { void percent_formatsPercent() { processor.setVariable("value", 0.125); - assertEquals( - "13%", - processor.process("{{$value | percent}}") - ); + processor.setTemplate("{{$value | percent}}"); + assertEquals("13%", processor.process()); } } @@ -454,40 +508,47 @@ class DefaultValues { @DisplayName("Should use first non-null value") void firstNonNull() { processor.setVariable("name", "Alice"); - assertEquals("Alice", processor.process("{{$name ?? \"Guest\"}}")); + + processor.setTemplate("{{$name ?? \"Guest\"}}"); + assertEquals("Alice", processor.process()); } @Test @DisplayName("Should fallback to default when variable is null") void fallbackToDefault() { - assertEquals("Guest", processor.process("{{$name ?? \"Guest\"}}")); + processor.setTemplate("{{$name ?? \"Guest\"}}"); + assertEquals("Guest", processor.process()); } @Test @DisplayName("Should chain multiple defaults") void chainedDefaults() { processor.setVariable("b", "Value B"); - assertEquals("Value B", processor.process("{{$a ?? $b ?? \"Default\"}}")); - assertEquals("Default", processor.process("{{$a ?? $c ?? \"Default\"}}")); + + processor.setTemplate("{{$a ?? $b ?? \"Default\"}}"); + assertEquals("Value B", processor.process()); + + processor.setTemplate("{{$a ?? $c ?? \"Default\"}}"); + assertEquals("Default", processor.process()); } @Test @DisplayName("Should combine defaults with filters") void defaultsWithFilters() { - assertEquals("GUEST", processor.process("{{$name | uppercase ?? \"GUEST\"}}")); + processor.setTemplate("{{$name | uppercase ?? \"GUEST\"}}"); + assertEquals("GUEST", processor.process()); processor.setVariable("name", "john"); - assertEquals("JOHN", processor.process("{{$name | uppercase ?? \"GUEST\"}}")); + assertEquals("JOHN", processor.process()); } @Test @DisplayName("Should handle complex expressions with defaults, filters, and properties") void complexDefaultExpression() { - record User(String firstName, String lastName) { - } processor.setVariable("user", new User(null, "Doe")); - assertEquals("DOE", processor.process("{{$user.firstName | uppercase ?? $user.lastName | uppercase ?? \"GUEST\"}}")); + processor.setTemplate("{{$user.firstName | uppercase ?? $user.lastName | uppercase ?? \"GUEST\"}}"); + assertEquals("DOE", processor.process()); } } @@ -501,14 +562,18 @@ class IfBlocks { @DisplayName("Should render content when condition is true") void renderWhenTrue() { processor.setVariable("show", true); - assertEquals("
    Visible
    ", processor.process("{{#if $show}}
    Visible
    {{/if}}")); + + processor.setTemplate("{{#if $show}}
    Visible
    {{/if}}"); + assertEquals("
    Visible
    ", processor.process()); } @Test @DisplayName("Should not render content when condition is false") void notRenderWhenFalse() { processor.setVariable("show", false); - assertEquals("", processor.process("{{#if $show}}
    Hidden
    {{/if}}")); + + processor.setTemplate("{{#if $show}}
    Hidden
    {{/if}}"); + assertEquals("", processor.process()); } @Test @@ -517,7 +582,8 @@ void complexConditions() { processor.setVariable("count", 5); processor.setVariable("enabled", true); - assertEquals("
    Show
    ", processor.process("{{#if $enabled && $count > 3}}
    Show
    {{/if}}")); + processor.setTemplate("{{#if $enabled && $count > 3}}
    Show
    {{/if}}"); + assertEquals("
    Show
    ", processor.process()); } @Test @@ -526,21 +592,22 @@ void nestedIf() { processor.setVariable("outer", true); processor.setVariable("inner", true); - String template = normalize(""" + processor.setTemplate(normalize(""" {{#if $outer}} Outer {{#if $inner}} Inner {{/if}} {{/if}} - """); + """)); - String result = processor.process(template); + String result = processor.process(); assertTrue(result.contains("Outer")); assertTrue(result.contains("Inner")); processor.setVariable("inner", false); - result = processor.process(template); + + result = processor.process(); assertTrue(result.contains("Outer")); assertFalse(result.contains("Inner")); } @@ -550,7 +617,7 @@ void nestedIf() { void complexIfComparison() { processor.setVariable("score", 85); - String template = normalize(""" + processor.setTemplate(normalize(""" {{#if $score >= 90}} A {{/if}} @@ -560,9 +627,9 @@ void complexIfComparison() { {{#if $score < 80}} C {{/if}} - """); + """)); - String result = processor.process(template); + String result = processor.process(); assertTrue(result.contains("B")); assertFalse(result.contains("A")); assertFalse(result.contains("C")); @@ -579,19 +646,19 @@ void rendersIfElseBranch(boolean render, boolean loggedIn, String expected) { processor.setVariable("render", render); processor.setVariable("loggedIn", loggedIn); - String template = """ + processor.setTemplate(""" {{#if $render}} {{#if $loggedIn}} Welcome back! - {{#else}} + {{else}} Please log in {{/if}} - {{#else}} + {{else}} Rendering is disabled {{/if}} - """; + """); - assertEquals(expected, processor.process(template).trim()); + assertEquals(expected, processor.process().trim()); } } @@ -605,32 +672,39 @@ class EachBlocks { @DisplayName("Should iterate with default item name") void iterateWithDefaultName() { processor.setVariable("items", List.of("A", "B", "C")); - assertEquals("A B C ", processor.process("{{#each $items}}{{$item}} {{/each}}")); + + processor.setTemplate("{{#each $items}}{{$item}} {{/each}}"); + assertEquals("A B C ", processor.process()); } @Test @DisplayName("Should iterate with custom item name") void iterateWithCustomName() { processor.setVariable("items", List.of("A", "B", "C")); - assertEquals("A B C ", processor.process("{{#each $items element}}{{$element}} {{/each}}")); + + processor.setTemplate("{{#each $items element}}{{$element}} {{/each}}"); + assertEquals("A B C ", processor.process()); } @Test @DisplayName("Should iterate over records with property access") void iterateRecords() { - record Item(String name, int value) { - } - processor.setVariable("items", List.of(new Item("First", 1), new Item("Second", 2))); + processor.setVariable("items", List.of(new Item("First", false, "First", 1), new Item("Second", true, "Second", 2))); - assertEquals("First:1 Second:2 ", processor.process("{{#each $items}}{{$item.name}}:{{$item.value}} {{/each}}")); - assertEquals("First:1 Second:2 ", processor.process("{{#each $items product}}{{$product.name}}:{{$product.value}} {{/each}}")); + processor.setTemplate("{{#each $items}}{{$item.name}}:{{$item.count}} {{/each}}"); + assertEquals("First:1 Second:2 ", processor.process()); + + processor.setTemplate("{{#each $items product}}{{$product.name}}:{{$product.count}} {{/each}}"); + assertEquals("First:1 Second:2 ", processor.process()); } @Test @DisplayName("Should handle empty collections") void emptyCollection() { processor.setVariable("items", List.of()); - assertEquals("", processor.process("{{#each $items}}{{$item}}{{/each}}")); + + processor.setTemplate("{{#each $items}}{{$item}}{{/each}}"); + assertEquals("", processor.process()); } @Test @@ -639,8 +713,11 @@ void globalVariablesInLoop() { processor.setVariable("prefix", "Item"); processor.setVariable("numbers", List.of(1, 2, 3)); - assertEquals("Item 1 Item 2 Item 3 ", processor.process("{{#each $numbers}}{{$prefix}} {{$item}} {{/each}}")); - assertEquals("Number 1 Number 2 Number 3 ", processor.process("{{#each $numbers num}}Number {{$num}} {{/each}}")); + processor.setTemplate("{{#each $numbers}}{{$prefix}} {{$item}} {{/each}}"); + assertEquals("Item 1 Item 2 Item 3 ", processor.process()); + + processor.setTemplate("{{#each $numbers num}}Number {{$num}} {{/each}}"); + assertEquals("Number 1 Number 2 Number 3 ", processor.process()); } @Test @@ -651,14 +728,14 @@ void nestedLoopsWithCustomNames() { new Category("Vegetables", List.of("Carrot", "Lettuce")) )); - String template = normalize(""" + processor.setTemplate(normalize(""" {{#each $categories cat}} {{$cat.name}}: {{#each $cat.items product}} - {{$product}} {{/each}} {{/each}} - """); + """)); assertEquals(normalize(""" Fruits: @@ -667,19 +744,20 @@ void nestedLoopsWithCustomNames() { Vegetables: - Carrot - Lettuce - """), processor.process(template)); + """), processor.process()); } @Test @DisplayName("Should handle null values in collections") void nullValuesInCollection() { - List items = new ArrayList<>(); - items.add("A"); - items.add(null); - items.add("C"); - processor.setVariable("items", items); + processor.setVariable("items", new ArrayList<>() {{ + add("A"); + add(null); + add("C"); + }}); - assertEquals("A,,C,", processor.process("{{#each $items}}{{$item}},{{/each}}")); + processor.setTemplate("{{#each $items}}{{$item}},{{/each}}"); + assertEquals("A,,C,", processor.process()); } } @@ -693,23 +771,23 @@ class CombinedBlocks { @DisplayName("Should combine if and each blocks") void ifInsideEach() { processor.setVariable("items", List.of( - new Item("First", true, null), - new Item("Second", false, null), - new Item("Third", true, null) + new Item("First", true, null, 0), + new Item("Second", false, null, 0), + new Item("Third", true, null, 0) )); - String template = normalize(""" + processor.setTemplate(normalize(""" {{#each $items}} {{#if $item.active}}
    {{$item.name}}
    {{/if}} {{/each}} - """); + """)); assertEquals(normalize("""
    First
    Third
    - """), processor.process(template)); + """), processor.process()); } @Test @@ -718,11 +796,12 @@ void complexRealWorldTemplate() { processor.setVariable("preset-active", "preset_01"); processor.setVariable("render", true); processor.setVariable("preset-list", List.of( - new Item("preset_01", true, "Test name"), - new Item("preset_02", true, "Test name 02") + new Item("preset_01", true, "Test name", 0), + new Item("preset_02", true, "Test name 02", 1) )); - String template = normalize(""" + + processor.setTemplate(normalize("""
    @@ -738,9 +817,8 @@ void complexRealWorldTemplate() {
    {{/if}}
    - """); + """)); - String result = processor.process(template); assertEquals(normalize("""
    @@ -754,43 +832,110 @@ void complexRealWorldTemplate() {
    - """), result); + """), processor.process()); } } // ========== COMPONENTS ========== -// @Nested -// @DisplayName("Components") -// class Components { -// -// @Test -// @DisplayName("Should expand simple component with parameters") -// void expandsComponentWithParameters() { -// processor.registerComponent( -// "button", -// "" -// ); -// -// assertEquals( -// "", -// processor.process("{{@button:text=Click Me,id=myBtn}}") -// ); -// } -// -// @Test -// @DisplayName("Should allow components to access variables from scope") -// void componentCanAccessVariablesFromScope() { -// processor -// .setVariable("label", "Submit") -// .registerComponent("button", ""); -// -// assertEquals( -// "", -// processor.process("{{@button}}") -// ); -// } -// } + @Nested + @DisplayName("Components") + class Components { + + @Test + @DisplayName("Should expand component with parameters") + void expandsComponentWithParameters() { + processor.setVariable("number", 12.847); + processor.registerComponent("statCard", """ +
    +

    {{$label}}

    +

    {{$value}}

    +
    + """); + + processor.setTemplate(""); + assertEquals(normalize(""" +
    +

    Blocks Placed

    +

    12.847

    +
    + """), processor.process()); + } + + @Test + @DisplayName("Should expand component inside another component with parameters") + void expandsComponentWithinComponent() { + processor.setVariable("text", "Deep Component"); + processor.registerComponent("panel", """ +
    + + +
    + """); + processor.registerComponent("view", """ + {{ $content ?? "undefined" }} + """); + + processor.setTemplate(""); + assertEquals(normalize(""" +
    + Deep Component + undefined +
    + """), processor.process()); + } + + @Test + @DisplayName("Should allow components to access variables from global scope") + void componentCanAccessVariablesFromGlobalScope() { + processor.setVariable("label", "Submit"); + processor.registerComponent("submit", ""); + + processor.setTemplate(""); + assertEquals( + "", + processor.process() + ); + } + + @Test + @DisplayName("Should prioritize local scope over global scope in components") + void componentPrioritizeVariableFromLocalScope() { + processor.setVariable("label", "global scope"); + processor.registerComponent("submit", ""); + + processor.setTemplate(""); + assertEquals( + "", + processor.process() + ); + } + + @Test + @DisplayName("Should allow components to pass existing parameters") + void componentCanPassExistingParameters() { + processor.registerComponent("button", ""); + + processor.setTemplate("", + processor.process() + ); + } + + @Test + @DisplayName("Should allow components to use children as content") + void componentCanPassChildren() { + processor.registerComponent("panel", "
    {{ $children }}
    "); + processor.registerComponent("bigButton", "

    {{ $children }}

    "); + + processor.setTemplate("Deep Big Button"); + assertEquals( + "

    Deep Big Button

    ", + processor.process() + ); + } + } // ========== ERROR HANDLING ========== @@ -801,33 +946,38 @@ class ErrorHandling { @Test @DisplayName("Should throw exception for unterminated string") void unterminatedString() { - assertThrows(RuntimeException.class, () -> processor.process("{{\"unterminated}}")); + processor.setTemplate("{{\"unterminated}}"); + assertThrows(RuntimeException.class, () -> processor.process()); } @Test @DisplayName("Should throw exception for unknown filter") void unknownFilter() { processor.setVariable("name", "John"); - assertThrows(RuntimeException.class, () -> processor.process("{{$name | unknownfilter}}")); + + processor.setTemplate("{{$name | unknownfilter}}"); + assertThrows(RuntimeException.class, () -> processor.process()); } @Test @DisplayName("Should throw exception for unclosed if block") void unclosedIfBlock() { - assertThrows(RuntimeException.class, () -> processor.process("{{#if $var}}Content")); + processor.setTemplate("{{#if $var}}Content"); + assertThrows(RuntimeException.class, () -> processor.process()); } @Test @DisplayName("Should throw exception for unclosed each block") void unclosedEachBlock() { - assertThrows(RuntimeException.class, () -> processor.process("{{#each $items}}Content")); + processor.setTemplate("{{#each $items}}Content"); + assertThrows(RuntimeException.class, () -> processor.process()); } } // ========== PERFORMANCE ========== @Nested - @DisplayName("Performance") + @DisplayName("Performance measurements") class Performance { @Test @@ -838,7 +988,8 @@ void largeListPerformance() { processor.setVariable("numbers", largeList); long start = System.currentTimeMillis(); - String result = processor.process("{{#each $numbers}}{{$item}},{{/each}}"); + processor.setTemplate("{{#each $numbers}}{{$item}},{{/each}}"); + String result = processor.process(); long duration = System.currentTimeMillis() - start; assertTrue(duration < 1000, "Processing 1000 items should complete in less than 1 second"); @@ -858,7 +1009,7 @@ void complexTemplatePerformance() { """); long start = System.currentTimeMillis(); - for (int i = 0; i < 100; i++) processor.process(template); + for (int i = 0; i < 100; i++) processor.setTemplate(template).process(); long duration = System.currentTimeMillis() - start; assertTrue(duration < 1000, "100 iterations should complete in less than 1 second"); diff --git a/src/test/java/au/ellie/hyui/html/ast/utils/NumericUtilsTest.java b/src/test/java/au/ellie/hyui/html/template/utils/NumericUtilsTest.java similarity index 97% rename from src/test/java/au/ellie/hyui/html/ast/utils/NumericUtilsTest.java rename to src/test/java/au/ellie/hyui/html/template/utils/NumericUtilsTest.java index 530502e..cb61b02 100644 --- a/src/test/java/au/ellie/hyui/html/ast/utils/NumericUtilsTest.java +++ b/src/test/java/au/ellie/hyui/html/template/utils/NumericUtilsTest.java @@ -1,4 +1,4 @@ -package au.ellie.hyui.html.ast.utils; +package au.ellie.hyui.html.template.utils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From f33fd3ac393a368488f32c54366fb6450316d2a5 Mon Sep 17 00:00:00 2001 From: Farrael Date: Fri, 6 Feb 2026 16:30:11 +0100 Subject: [PATCH 05/30] feat: Add variable function to replace method call on objects --- .../au/ellie/hyui/html/TemplateProcessor.java | 5 +- .../ellie/hyui/html/template/Evaluator.java | 60 +++---- .../html/template/context/VariableStack.java | 142 +++++++++++++--- .../hyui/html/template/utils/LambdaUtils.java | 159 ++++++++++++++++++ .../html/template/utils/ReflectionUtils.java | 43 +++++ .../hyui/html/TemplateProcessorTest.java | 43 +++++ 6 files changed, 389 insertions(+), 63 deletions(-) create mode 100644 src/main/java/au/ellie/hyui/html/template/utils/LambdaUtils.java diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index 5dbc3b7..d84f4f8 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -25,6 +25,7 @@ import au.ellie.hyui.html.template.Parser; import au.ellie.hyui.html.template.context.FilterRegistry; import au.ellie.hyui.html.template.context.VariableStack; +import au.ellie.hyui.html.template.context.VariableStack.VariableScope; import au.ellie.hyui.html.template.item.Node; import au.ellie.hyui.html.template.item.Token; @@ -45,6 +46,7 @@ import java.util.function.Supplier; import static au.ellie.hyui.html.template.context.VariableStack.NULL_SENTINEL; +import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.ROOT_SCOPE_NAME; /** * Preprocessor for HyUIML templates that supports variable interpolation and component inclusion. @@ -250,7 +252,8 @@ public String process(@Nullable Map additionalVariables) { if (additionalVariables != null) parameters.putAll(additionalVariables); - var stack = new VariableStack(parameters, valueResolver, preferDynamicValues); + var scope = new VariableScope(ROOT_SCOPE_NAME, parameters); + var stack = new VariableStack(scope, valueResolver, preferDynamicValues); var rootAst = this.root.getAst(components); // Evaluator diff --git a/src/main/java/au/ellie/hyui/html/template/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java index 104e4c7..8dba4f0 100644 --- a/src/main/java/au/ellie/hyui/html/template/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -22,6 +22,7 @@ import au.ellie.hyui.html.TemplateProcessor.CachedComponent; import au.ellie.hyui.html.template.context.FilterRegistry; import au.ellie.hyui.html.template.context.VariableStack; +import au.ellie.hyui.html.template.context.VariableStack.VariableScope; import au.ellie.hyui.html.template.item.Node; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; @@ -41,7 +42,6 @@ import au.ellie.hyui.html.template.utils.NumericUtils; import au.ellie.hyui.html.template.utils.ReflectionUtils; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; @@ -50,6 +50,9 @@ import java.util.Objects; import java.util.function.Supplier; +import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.COMPONENT_SCOPE_PREFIX; +import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.EACH_SCOPE_NAME; + public class Evaluator { private final FilterRegistry filterRegistry; private final VariableStack contextStack; @@ -107,11 +110,21 @@ private Object evaluateExpression(ExpressionNode node) { return switch (node) { case TextNode literal -> literal.content(); case LiteralNode literal -> literal.value(); - case VariableNode var -> contextStack.getVariable(var.name()); case PropertyAccessNode prop -> evaluatePropertyAccess(prop); case BinaryOpNode binary -> evaluateBinaryOp(binary); case PipeNode pipe -> evaluatePipe(pipe); case DefaultNode def -> evaluateDefault(def); + case VariableNode var -> contextStack.getVariable(var.name(), () -> { + for (String key : contextStack.getScopeKeys()) { + try { + return ReflectionUtils.getObjectProperty(contextStack.getVariable(key), var.name()); + } catch (Exception _) { + // Ignore and return null + } + } + + return null; + }); }; } @@ -127,35 +140,8 @@ private Object evaluatePropertyAccess(PropertyAccessNode node) { var property = node.property(); - // Access via Map - if (obj instanceof Map map) - return map.get(property); - - // Access via Reflection try { - var clazz = obj.getClass(); - - try { - var field = clazz.getDeclaredField(property); - field.setAccessible(true); - - return field.get(obj); - } catch (NoSuchFieldException e) { - var propName = property.substring(0, 1).toUpperCase() + property.substring(1); - var methodNames = new ArrayList() {{ - add(property); - add("get" + propName); - add("is" + propName); - }}; - - // Open methods - for (var name : methodNames) { - var method = ReflectionUtils.getPublicMethod(clazz, name); - - if (method.isPresent()) - return method.get().invoke(obj); - } - } + return ReflectionUtils.getObjectProperty(obj, property); } catch (Exception _) { HyUIPlugin.getLog().logWarn("Error accessing property " + property + " on " + obj.getClass()); } @@ -302,10 +288,10 @@ private String evaluateEachBlock(EachBlockNode node) { var result = new StringBuilder(); for (Object item : items) { - var context = new HashMap(); - context.put(node.itemName(), item); + var scope = new VariableScope(EACH_SCOPE_NAME); + scope.putKeyed(node.itemName(), item); - contextStack.pushScope(context); + contextStack.pushScope(scope); try { for (Node child : node.body()) result.append(evaluateNode(child)); @@ -341,14 +327,15 @@ private String evaluateComponent(ComponentElementNode component) { } } - context.put("children", (Supplier) () -> { + var scope = new VariableScope(COMPONENT_SCOPE_PREFIX + componentDef, context); + scope.putKeyed("children", (Supplier) () -> { var result = new StringBuilder(); for (Node child : component.children()) result.append(evaluateNode(child)); return result.toString(); }); - contextStack.pushScope(context); + contextStack.pushScope(scope); try { return evaluate(cachedComponent.getAst(components)); } finally { @@ -386,6 +373,9 @@ private Iterable toIterable(Object value) { if (value instanceof Iterable iterable) return iterable; + if (value instanceof Map map) + return map.entrySet(); + if (value.getClass().isArray()) return Arrays.asList((Object[]) value); diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java index 2bb12d0..e814268 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java @@ -19,32 +19,45 @@ package au.ellie.hyui.html.template.context; import au.ellie.hyui.html.TemplateProcessor.ValueResolver; +import au.ellie.hyui.html.template.utils.LambdaUtils; +import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Optional; -import java.util.function.Function; +import java.util.Set; import java.util.function.Supplier; public class VariableStack { public static final Object NULL_SENTINEL = new Object(); - private final ArrayDeque> stack = new ArrayDeque<>(); + private final ArrayDeque stack = new ArrayDeque<>(); private final ValueResolver valueResolver; private final boolean preferDynamicValues; - public VariableStack(Map globalScope, @Nullable ValueResolver valueResolver, boolean preferDynamicValues) { + public VariableStack(VariableScope globalScope, @Nullable ValueResolver valueResolver, boolean preferDynamicValues) { this.valueResolver = valueResolver; this.preferDynamicValues = preferDynamicValues; pushScope(globalScope); } - public void pushScope(Map scope) { + /** + * Push a new scope onto the stack. + * + * @param scope Scope to push + */ + public void pushScope(VariableScope scope) { stack.push(scope); } + /** + * Pop the current scope from the stack. + * Cannot pop the global scope. + */ public void popScope() { if (stack.size() > 1) stack.pop(); @@ -69,7 +82,7 @@ public Object getVariable(String name) { * @param defaultValue Default value if variable not found * @return Variable value, or defaultValue if not found */ - public Object getVariable(String name, Object defaultValue) { + public Object getVariable(String name, Supplier defaultValue) { // Check dynamic values first if preferred if (preferDynamicValues && valueResolver != null) { Optional resolved = valueResolver.resolve(name); @@ -77,30 +90,19 @@ public Object getVariable(String name, Object defaultValue) { return resolved; } - for (Map scope : stack) { + for (VariableScope scope : stack) { if (scope.containsKey(name)) { var object = scope.get(name); + if (object == null) + return null; + + if (object instanceof Supplier supplier) { + object = supplier.get(); + scope.put(name, object); + } else if (LambdaUtils.isFunction(object)) + object = LambdaUtils.call(object, this, defaultValue); - switch (object) { - case Supplier supplier -> { - object = supplier.get(); - scope.put(name, object); - return object; - } - case Function function -> { - @SuppressWarnings("unchecked") - Function stackFunction = - (Function) function; - - return stackFunction.apply(this); - } - case null -> { - return null; - } - default -> { - return object; - } - } + return object; } } @@ -113,6 +115,92 @@ public Object getVariable(String name, Object defaultValue) { } } - return defaultValue; + return defaultValue.get(); + } + + /** + * Get the name of the current scope. + */ + @Nonnull + public String getScopeName() { + var scope = stack.peek(); + + return scope != null ? scope.getName() : "none"; + } + + /** + * Check if the current scope has the given name. + * + * @param name Scope name to check + */ + public boolean isScope(String name) { + var scope = stack.peek(); + + return scope != null && scope.getName().equals(name); + } + + /** + * Get the keys of the current scope, if available. + */ + public Set getScopeKeys() { + var scope = stack.peek(); + + return scope != null ? scope.getKeys() : null; + } + + // ===== Scope ===== + + public static class VariableScope { + + public static final String COMPONENT_SCOPE_PREFIX = "component:"; + public static final String ROOT_SCOPE_NAME = "root"; + public static final String EACH_SCOPE_NAME = "each"; + + private final Map content; + private final Set keys; + private final String name; + + public VariableScope(String name) { + this(name, new HashMap<>(), new HashSet<>()); + } + + public VariableScope(String name, Map content) { + this(name, content, new HashSet<>()); + } + + public VariableScope(String name, Map content, @Nonnull Set keys) { + this.name = name; + this.content = content; + this.keys = keys; + } + + public String getName() { + return name; + } + + public Map getContent() { + return content; + } + + public Set getKeys() { + return keys; + } + + public boolean containsKey(String key) { + return content.containsKey(key); + } + + public Object get(String key) { + return content.get(key); + } + + public void put(String key, Object value) { + content.put(key, value); + } + + public void putKeyed(String key, Object value) { + content.put(key, value); + keys.add(key); + } } } diff --git a/src/main/java/au/ellie/hyui/html/template/utils/LambdaUtils.java b/src/main/java/au/ellie/hyui/html/template/utils/LambdaUtils.java new file mode 100644 index 0000000..b9d773d --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/utils/LambdaUtils.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.utils; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.concurrent.Callable; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Quick utility for detecting and calling multiple type of lambda/function objects, + * including Java functional interfaces and Kotlin functions. + */ +public class LambdaUtils { + + /** + * Check if an object is a callable function/lambda + * + * @param obj The object to check + */ + public static boolean isFunction(Object obj) { + if (obj == null) + return false; + + // Check Kotlin functions + if (obj.getClass().getName().startsWith("kotlin.jvm.functions.Function")) + return true; + + // Check Java functional interfaces + return obj instanceof Supplier || obj instanceof Function || + obj instanceof BiFunction || obj instanceof Consumer || + obj instanceof BiConsumer || obj instanceof Predicate || + obj instanceof Runnable || obj instanceof Callable; + } + + /** + * Call a function with the given arguments + * Automatically detects the function type and calls it appropriately + * + * @param source The function object to call + * @param args Arguments to pass to the function + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static Object call(Object source, Object... args) { + switch (source) { + case Supplier supplier -> { + return supplier.get(); + } + case Function function when args.length >= 1 -> { + return function.apply(args[0]); + } + case BiFunction biFunction when args.length >= 2 -> { + return biFunction.apply(args[0], args[1]); + } + case Consumer consumer when args.length >= 1 -> { + consumer.accept(args[0]); + return null; + } + case BiConsumer biConsumer when args.length >= 2 -> { + biConsumer.accept(args[0], args[1]); + return null; + } + case Runnable runnable -> { + runnable.run(); + return null; + } + case Callable callable -> { + try { + return callable.call(); + } catch (Exception e) { + throw new RuntimeException("Error calling Callable", e); + } + } + case Predicate predicate when args.length >= 1 -> { + return predicate.test(args[0]); + } + default -> { + // Continue to reflection fallback + } + } + + try { + return callViaReflection(source, args); + } catch (Exception e) { + throw new RuntimeException("Failed to call function", e); + } + } + + /** + * Call a function using reflection (fallback method) + * + * @param function The function object to call + * @param args Arguments to pass to the function + * @return The result of the function call + */ + private static Object callViaReflection(Object function, Object... args) throws Exception { + var clazz = function.getClass(); + + // Try to find "invoke" method (Kotlin) + try { + var invokeMethod = clazz.getMethod("invoke"); + + return invokeMethod.invoke(function, args); + } catch (NoSuchMethodException e) { + // Continue to SAM method search + } + + // Find the single abstract method + var sam = findSingleAbstractMethod(clazz); + if (sam != null) + return sam.invoke(function, args); + + throw new IllegalArgumentException("Cannot find callable method on " + clazz); + } + + /** + * Find the single abstract method (SAM) of a class, if it exists + * + * @param clazz The class to inspect + * @return The single abstract method, or null if there are none or more than one + */ + private static Method findSingleAbstractMethod(Class clazz) { + Method abstractMethod = null; + for (var method : clazz.getMethods()) { + if (Modifier.isAbstract(method.getModifiers()) && + !method.isDefault() && + !method.getDeclaringClass().equals(Object.class)) { + + if (abstractMethod != null) + return null; // More than one abstract method + + abstractMethod = method; + } + } + + return abstractMethod; + } +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java b/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java index f53ae99..9364bfa 100644 --- a/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java +++ b/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -31,6 +32,48 @@ */ public class ReflectionUtils { + /** + * Get the value of a property from an object, + * supporting both Map access and reflection. + * + * @param obj Object to access + * @param propertyName Name of the property to retrieve + * @return The value of the property, or null if not found + * @throws Exception If an error occurs during reflection access + */ + public static Object getObjectProperty(Object obj, String propertyName) throws Exception { + // Map access + if (obj instanceof Map map) + return map.get(propertyName); + + // Reflection access + var clazz = obj.getClass(); + + try { + var field = clazz.getDeclaredField(propertyName); + field.setAccessible(true); + + return field.get(obj); + } catch (NoSuchFieldException e) { + var propName = propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1); + var methodNames = new ArrayList() {{ + add(propertyName); + add("get" + propName); + add("is" + propName); + }}; + + // Open methods + for (var name : methodNames) { + var method = ReflectionUtils.getPublicMethod(clazz, name); + + if (method.isPresent()) + return method.get().invoke(obj); + } + } + + return null; + } + /** * Get a truly public method (i.e., a method that is public and declared in a public class or interface) * from the given class or its superclasses/interfaces, avoiding calling method on packages with restricted access. diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index e9742e5..1055716 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -11,7 +11,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.EACH_SCOPE_NAME; import static org.junit.jupiter.api.Assertions.*; class TemplateProcessorTest { @@ -71,6 +73,9 @@ record Box(int size) { } record Product(String name, List tags) { + public String getTagsWithPrefix(String prefix) { + return tags.stream().map(tag -> prefix + tag).collect(Collectors.joining(", ")); + } } // ========== BASIC FUNCTIONALITY ========== @@ -761,6 +766,44 @@ void nullValuesInCollection() { } } + // ========== FUNCTION CALLS ========== + + @Nested + @DisplayName("Function Calls in Templates") + class FunctionCalls { + + @Test + @DisplayName("Should call function with arguments") + void callFunctionWithArguments() { + processor.setVariable("products", List.of( + new Product("Weapon", List.of("sword", "axe")), + new Product("Potion", List.of("healing", "mana")) + )); + processor.setVariable("tags", (stack) -> { + if (!stack.isScope(EACH_SCOPE_NAME)) + return ""; + + String key = stack.getScopeKeys().iterator().next(); + Object value = stack.getVariable(key); + if (!(value instanceof Product product)) + return ""; + + return product.getTagsWithPrefix("tag_"); + }); + + processor.setTemplate(normalize(""" + {{#each $products product}} + {{$name}}: {{$tags}} + {{/each}} + """)); + + assertEquals(normalize(""" + Weapon: tag_sword, tag_axe + Potion: tag_healing, tag_mana + """), processor.process()); + } + } + // ========== COMBINED BLOCKS ========== @Nested From a45cacbbafb1a7611b90e5668cbf70fd9b324688 Mon Sep 17 00:00:00 2001 From: Farrael Date: Fri, 6 Feb 2026 23:12:03 +0100 Subject: [PATCH 06/30] feat: process dynamique variables as attributes --- .../au/ellie/hyui/html/TemplateProcessor.java | 5 +- .../ellie/hyui/html/template/Evaluator.java | 113 +++++++++++++++++- .../au/ellie/hyui/html/template/Parser.java | 17 +-- .../ellie/hyui/html/template/item/Node.java | 19 ++- .../hyui/html/TemplateProcessorTest.java | 36 ++++++ 5 files changed, 162 insertions(+), 28 deletions(-) diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index d84f4f8..2a5852d 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -243,7 +243,7 @@ public String process(@Nullable UIContext context) { /** * Processes the template with additional variables that can override existing ones. * - * @param additionalVariables Additional variables to use during processing (can override existing variables) + * @param additionalVariables Additional variables to use during processing * @return The processed template. */ public String process(@Nullable Map additionalVariables) { @@ -252,11 +252,10 @@ public String process(@Nullable Map additionalVariables) { if (additionalVariables != null) parameters.putAll(additionalVariables); + var rootAst = this.root.getAst(components); var scope = new VariableScope(ROOT_SCOPE_NAME, parameters); var stack = new VariableStack(scope, valueResolver, preferDynamicValues); - var rootAst = this.root.getAst(components); - // Evaluator return new Evaluator(stack, filterRegistry, components).evaluate(rootAst); } diff --git a/src/main/java/au/ellie/hyui/html/template/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java index 8dba4f0..e9a8b62 100644 --- a/src/main/java/au/ellie/hyui/html/template/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -24,7 +24,9 @@ import au.ellie.hyui.html.template.context.VariableStack; import au.ellie.hyui.html.template.context.VariableStack.VariableScope; import au.ellie.hyui.html.template.item.Node; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.ExpressionAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.StaticAttributeNode; import au.ellie.hyui.html.template.item.Node.BlockNode.EachBlockNode; @@ -95,6 +97,7 @@ private String evaluateNode(Node node) { case IfBlockNode ifBlock -> evaluateIfBlock(ifBlock); case EachBlockNode eachBlock -> evaluateEachBlock(eachBlock); case ComponentElementNode component -> evaluateComponent(component); + case AttributeValueNode attributeValueNode -> evaluateAttributeValueNode(attributeValueNode); default -> throw new IllegalStateException("Unexpected value: " + node); }; @@ -310,23 +313,30 @@ private String evaluateEachBlock(EachBlockNode node) { * @return The resulting string after evaluation. */ private String evaluateComponent(ComponentElementNode component) { - var componentDef = component.tagName(); + var componentDef = component.tag(); var cachedComponent = components.get(componentDef); if (cachedComponent == null) throw new RuntimeException("Component not found: " + componentDef); + // Attributes var context = new HashMap(); - for (var entry : component.attributes().entrySet()) { - switch (entry.getValue()) { + for (var attribute : component.attributes()) { + switch (attribute) { case DynamicAttributeNode dynamicAttributeNode -> - context.put(entry.getKey(), evaluateExpression(dynamicAttributeNode.expression())); + context.put(attribute.getName(), evaluateExpression(dynamicAttributeNode.expression())); case StaticAttributeNode staticAttributeNode -> - context.put(entry.getKey(), staticAttributeNode.value()); - case FlagAttributeNode _ -> context.put(entry.getKey(), true); + context.put(attribute.getName(), staticAttributeNode.value()); + case ExpressionAttributeNode expressionAttributeNode -> { + var evaluatedValue = evaluateNode(expressionAttributeNode.expressions()); + if (!evaluatedValue.isEmpty()) + parseInlineAttributes(evaluatedValue, context); + } + case FlagAttributeNode _ -> context.put(attribute.getName(), true); } } + // Children var scope = new VariableScope(COMPONENT_SCOPE_PREFIX + componentDef, context); scope.putKeyed("children", (Supplier) () -> { var result = new StringBuilder(); @@ -343,6 +353,97 @@ private String evaluateComponent(ComponentElementNode component) { } } + /** + * Evaluate an attribute value node and return the resulting string. + * + * @param attributeValueNode The attribute value node to evaluate. + * @return The resulting string after evaluation. + */ + private String evaluateAttributeValueNode(AttributeValueNode attributeValueNode) { + var sb = new StringBuilder(); + + switch (attributeValueNode) { + case DynamicAttributeNode dynamic -> + sb.append(dynamic.getName()).append("=\"").append(evaluateNode(dynamic.expression())).append("\""); + case StaticAttributeNode staticNode -> + sb.append(staticNode.getName()).append("=\"").append(staticNode.value()).append("\""); + case FlagAttributeNode flag -> sb.append(flag.getName()); + default -> { + // We don't support expression attributes here + } + } + + return sb.toString(); + } + + /** + * Parse inline attributes from evaluated expression content. + * Handles formats like: "--selected", "--name=value", "class=active" + * + * @param content The evaluated content containing attributes + * @param context The context map to add attributes to + */ + private void parseInlineAttributes(String content, Map context) { + var trimmed = content.trim(); + var len = trimmed.length(); + var i = 0; + + while (i < len) { + while (i < len && Character.isWhitespace(trimmed.charAt(i))) + i++; + + if (i >= len) + break; + + var nameStart = i; + + // Skip whitespaces + while (i < len && !Character.isWhitespace(trimmed.charAt(i)) && trimmed.charAt(i) != '=') + i++; + + String name = trimmed.substring(nameStart, i); + if (name.isEmpty()) + break; + + // Skip whitespaces + while (i < len && Character.isWhitespace(trimmed.charAt(i))) + i++; + + if (i < len && trimmed.charAt(i) == '=') { + + do i++; + while (i < len && Character.isWhitespace(trimmed.charAt(i))); + + // Read value + String value; + if (i < len && (trimmed.charAt(i) == '"' || trimmed.charAt(i) == '\'')) { + var quote = trimmed.charAt(i++); + nameStart = i; + + // Find closing quote + while (i < len && trimmed.charAt(i) != quote) + i++; + + value = trimmed.substring(nameStart, i); + + // Skip closing quote + if (i < len) + i++; + } else { + // Unquoted value - read until whitespace + int valueStart = i; + while (i < len && !Character.isWhitespace(trimmed.charAt(i))) + i++; + + value = trimmed.substring(valueStart, i); + } + + context.put(name, value); + } else + context.put(name, true); + } + } + // ===== Helpers ===== /** diff --git a/src/main/java/au/ellie/hyui/html/template/Parser.java b/src/main/java/au/ellie/hyui/html/template/Parser.java index 793ce19..6c94c75 100644 --- a/src/main/java/au/ellie/hyui/html/template/Parser.java +++ b/src/main/java/au/ellie/hyui/html/template/Parser.java @@ -22,6 +22,7 @@ import au.ellie.hyui.html.template.item.Node; import au.ellie.hyui.html.template.item.Node.AttributeValueNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; +import au.ellie.hyui.html.template.item.Node.AttributeValueNode.ExpressionAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.StaticAttributeNode; import au.ellie.hyui.html.template.item.Node.BlockNode.EachBlockNode; @@ -40,7 +41,7 @@ import au.ellie.hyui.html.template.item.Token.Type; import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Stack; @@ -346,16 +347,16 @@ private ComponentElementNode parseComponentElement() { context.push(identifier); // Parse attributes - var attributes = new LinkedHashMap(); - var blockAttributes = new ArrayList(); + var attributes = new LinkedList(); try { while (!peek(COMPONENT_CLOSE) && !isAtEnd()) { var attribute = parseAttribute(); if (attribute instanceof AttributeValueNode attrNode) - attributes.put(attrNode.getName(), attrNode); - else - blockAttributes.add(attribute); + attributes.add(attrNode); + else { + + } } } finally { context.pop(); @@ -365,7 +366,7 @@ private ComponentElementNode parseComponentElement() { var selfClosing = expect(COMPONENT_CLOSE).match(Symbols.COMPONENT_SELF_CLOSE); var children = selfClosing ? new ArrayList() : parseHtmlChildren(identifier.value()); - return new ComponentElementNode(identifier.value(), attributes, blockAttributes, children); + return new ComponentElementNode(identifier.value(), attributes, children); } /** @@ -397,7 +398,7 @@ private Node parseAttribute() { throw new RuntimeException("Expected attribute value at position " + current().position()); } else if (peek(EXPRESSION_OPEN)) - return parseExpressionOrBlock(); + return new ExpressionAttributeNode(parseExpressionOrBlock()); throw new RuntimeException("Unexpected token in tag <" + context.value() + ">: " + current()); } diff --git a/src/main/java/au/ellie/hyui/html/template/item/Node.java b/src/main/java/au/ellie/hyui/html/template/item/Node.java index a500f6b..ced1794 100644 --- a/src/main/java/au/ellie/hyui/html/template/item/Node.java +++ b/src/main/java/au/ellie/hyui/html/template/item/Node.java @@ -19,7 +19,6 @@ package au.ellie.hyui.html.template.item; import java.util.List; -import java.util.Map; public interface Node { @@ -107,21 +106,19 @@ public String getName() { return name; } } + + record ExpressionAttributeNode(Node expressions) implements AttributeValueNode { + public String getName() { + return ""; + } + } } record ComponentElementNode( - String tagName, - Map attributes, - List expressionAttributes, + String tag, + List attributes, List children ) implements Node { - public boolean hasAttribute(String name) { - return attributes.containsKey(name); - } - - public AttributeValueNode getAttribute(String name) { - return attributes.get(name); - } } } diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index 1055716..fb0ea74 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -78,6 +78,15 @@ public String getTagsWithPrefix(String prefix) { } } + class Modulator { + public int size; + + public int increment() { + size = (size + 1) % 2; + return size; + } + } + // ========== BASIC FUNCTIONALITY ========== @Nested @@ -802,6 +811,33 @@ void callFunctionWithArguments() { Potion: tag_healing, tag_mana """), processor.process()); } + + @Test + @DisplayName("Should call function with arguments and dynamic variables") + void callFunctionWithArgumentsAndDynamic() { + final var modulator = new Modulator(); + + processor.setVariable("list", List.of(1, 2, 3, 4)); + processor.setVariable("modulation", (_) -> modulator.increment()); + processor.setVariable("style", (stack) -> (int) stack.getVariable("key") < 3 ? "color: red;" : null); + + processor.registerComponent("module", """ + Module {{$key}} -> Active : {{ $active ?? false }} + """); + + processor.setTemplate(normalize(""" + {{#each $list key}} + + {{/each}} + """)); + + assertEquals(normalize(""" +
    Module 1 -> Active : false
    +
    Module 2 -> Active : true
    +
    Module 3 -> Active : false
    +
    Module 4 -> Active : false
    + """), processor.process()); + } } // ========== COMBINED BLOCKS ========== From 2e3358ec867822409f43bfe91fc9b2efdf3b2e3b Mon Sep 17 00:00:00 2001 From: Farrael Date: Fri, 6 Feb 2026 23:18:50 +0100 Subject: [PATCH 07/30] fix: missing imports --- src/main/java/au/ellie/hyui/html/HtmlParser.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/au/ellie/hyui/html/HtmlParser.java b/src/main/java/au/ellie/hyui/html/HtmlParser.java index e3135f3..9dcef9a 100644 --- a/src/main/java/au/ellie/hyui/html/HtmlParser.java +++ b/src/main/java/au/ellie/hyui/html/HtmlParser.java @@ -22,8 +22,11 @@ import au.ellie.hyui.builders.InterfaceBuilder; import au.ellie.hyui.builders.LabelBuilder; import au.ellie.hyui.builders.UIElementBuilder; +import au.ellie.hyui.html.handlers.BlockSelectorHandler; import au.ellie.hyui.html.handlers.ButtonHandler; +import au.ellie.hyui.html.handlers.ColorPickerDropdownBoxHandler; import au.ellie.hyui.html.handlers.DivHandler; +import au.ellie.hyui.html.handlers.HotkeyLabelHandler; import au.ellie.hyui.html.handlers.HyvatarHandler; import au.ellie.hyui.html.handlers.ImgHandler; import au.ellie.hyui.html.handlers.InputHandler; @@ -31,7 +34,11 @@ import au.ellie.hyui.html.handlers.ItemIconHandler; import au.ellie.hyui.html.handlers.ItemSlotHandler; import au.ellie.hyui.html.handlers.LabelHandler; +import au.ellie.hyui.html.handlers.LabeledCheckBoxHandler; +import au.ellie.hyui.html.handlers.MenuItemHandler; import au.ellie.hyui.html.handlers.ProgressBarHandler; +import au.ellie.hyui.html.handlers.ReorderableListGripHandler; +import au.ellie.hyui.html.handlers.SceneBlurHandler; import au.ellie.hyui.html.handlers.SelectHandler; import au.ellie.hyui.html.handlers.SpriteHandler; import au.ellie.hyui.html.handlers.TabContentHandler; From 744b12c089f9f2508dc9ea855d7e5424ae480cad Mon Sep 17 00:00:00 2001 From: Farrael Date: Mon, 9 Feb 2026 01:02:23 +0100 Subject: [PATCH 08/30] fix: address review feedback --- .../java/au/ellie/hyui/html/HtmlParser.java | 40 ++------- .../au/ellie/hyui/html/TemplateProcessor.java | 2 +- .../ellie/hyui/html/template/Evaluator.java | 83 ++++--------------- .../au/ellie/hyui/html/template/Lexer.java | 2 +- .../au/ellie/hyui/html/template/Parser.java | 35 +++----- .../template/context/EvaluationException.java | 50 +++++++++++ .../html/template/context/FilterRegistry.java | 41 ++++----- .../template/context/ParserException.java | 40 +++++++++ .../html/template/context/VariableStack.java | 2 +- .../template => }/utils/LambdaUtils.java | 2 +- .../template => }/utils/NumericUtils.java | 2 +- .../java/au/ellie/hyui/utils/ObjectUtils.java | 63 ++++++++++++++ .../template => }/utils/ReflectionUtils.java | 2 +- .../java/au/ellie/hyui/utils/StringUtils.java | 72 ++++++++++++++++ .../html/template/utils/NumericUtilsTest.java | 1 + 15 files changed, 289 insertions(+), 148 deletions(-) create mode 100644 src/main/java/au/ellie/hyui/html/template/context/EvaluationException.java create mode 100644 src/main/java/au/ellie/hyui/html/template/context/ParserException.java rename src/main/java/au/ellie/hyui/{html/template => }/utils/LambdaUtils.java (99%) rename src/main/java/au/ellie/hyui/{html/template => }/utils/NumericUtils.java (98%) create mode 100644 src/main/java/au/ellie/hyui/utils/ObjectUtils.java rename src/main/java/au/ellie/hyui/{html/template => }/utils/ReflectionUtils.java (99%) create mode 100644 src/main/java/au/ellie/hyui/utils/StringUtils.java diff --git a/src/main/java/au/ellie/hyui/html/HtmlParser.java b/src/main/java/au/ellie/hyui/html/HtmlParser.java index 9dcef9a..fb8224d 100644 --- a/src/main/java/au/ellie/hyui/html/HtmlParser.java +++ b/src/main/java/au/ellie/hyui/html/HtmlParser.java @@ -22,29 +22,7 @@ import au.ellie.hyui.builders.InterfaceBuilder; import au.ellie.hyui.builders.LabelBuilder; import au.ellie.hyui.builders.UIElementBuilder; -import au.ellie.hyui.html.handlers.BlockSelectorHandler; -import au.ellie.hyui.html.handlers.ButtonHandler; -import au.ellie.hyui.html.handlers.ColorPickerDropdownBoxHandler; -import au.ellie.hyui.html.handlers.DivHandler; -import au.ellie.hyui.html.handlers.HotkeyLabelHandler; -import au.ellie.hyui.html.handlers.HyvatarHandler; -import au.ellie.hyui.html.handlers.ImgHandler; -import au.ellie.hyui.html.handlers.InputHandler; -import au.ellie.hyui.html.handlers.ItemGridHandler; -import au.ellie.hyui.html.handlers.ItemIconHandler; -import au.ellie.hyui.html.handlers.ItemSlotHandler; -import au.ellie.hyui.html.handlers.LabelHandler; -import au.ellie.hyui.html.handlers.LabeledCheckBoxHandler; -import au.ellie.hyui.html.handlers.MenuItemHandler; -import au.ellie.hyui.html.handlers.ProgressBarHandler; -import au.ellie.hyui.html.handlers.ReorderableListGripHandler; -import au.ellie.hyui.html.handlers.SceneBlurHandler; -import au.ellie.hyui.html.handlers.SelectHandler; -import au.ellie.hyui.html.handlers.SpriteHandler; -import au.ellie.hyui.html.handlers.TabContentHandler; -import au.ellie.hyui.html.handlers.TabNavigationHandler; -import au.ellie.hyui.html.handlers.TextAreaHandler; -import au.ellie.hyui.html.handlers.TimerHandler; +import au.ellie.hyui.html.handlers.*; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -98,21 +76,21 @@ public void registerHandler(TagHandler handler) { } /** - * Gets the current template processor. + * Sets the template processor for variable interpolation and component inclusion. * - * @return The template processor, or null if not set. + * @param processor The template processor to use. */ - public TemplateProcessor getTemplateProcessor() { - return templateProcessor; + public void setTemplateProcessor(TemplateProcessor processor) { + this.templateProcessor = processor; } /** - * Sets the template processor for variable interpolation and component inclusion. + * Gets the current template processor. * - * @param processor The template processor to use. + * @return The template processor, or null if not set. */ - public void setTemplateProcessor(TemplateProcessor processor) { - this.templateProcessor = processor; + public TemplateProcessor getTemplateProcessor() { + return templateProcessor; } /** diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index 2a5852d..95d5bf7 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -375,7 +375,7 @@ public boolean invalidate() { public List getAst(Map componentCache) { if (ast == null) { List tokens = new Lexer(template, componentCache, name).tokenize(); - ast = new Parser(tokens, componentCache).parse(); + ast = new Parser(tokens).parse(); } return ast; diff --git a/src/main/java/au/ellie/hyui/html/template/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java index e9a8b62..14c6091 100644 --- a/src/main/java/au/ellie/hyui/html/template/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -20,6 +20,8 @@ import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.html.TemplateProcessor.CachedComponent; +import au.ellie.hyui.html.template.context.EvaluationException; +import au.ellie.hyui.html.template.context.EvaluationException.ComponentNotFoundException; import au.ellie.hyui.html.template.context.FilterRegistry; import au.ellie.hyui.html.template.context.VariableStack; import au.ellie.hyui.html.template.context.VariableStack.VariableScope; @@ -33,27 +35,18 @@ import au.ellie.hyui.html.template.item.Node.BlockNode.IfBlockNode; import au.ellie.hyui.html.template.item.Node.ComponentElementNode; import au.ellie.hyui.html.template.item.Node.ExpressionNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.BinaryOpNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.DefaultNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.LiteralNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.PipeNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.PropertyAccessNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.TextNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.VariableNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.*; import au.ellie.hyui.html.template.item.Symbols; -import au.ellie.hyui.html.template.utils.NumericUtils; -import au.ellie.hyui.html.template.utils.ReflectionUtils; - -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import au.ellie.hyui.utils.NumericUtils; +import au.ellie.hyui.utils.ReflectionUtils; + +import java.util.*; import java.util.function.Supplier; import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.COMPONENT_SCOPE_PREFIX; import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.EACH_SCOPE_NAME; +import static au.ellie.hyui.utils.ObjectUtils.toBoolean; +import static au.ellie.hyui.utils.ObjectUtils.toIterable; public class Evaluator { private final FilterRegistry filterRegistry; @@ -165,15 +158,15 @@ private Object evaluateBinaryOp(BinaryOpNode node) { return switch (node.operator()) { case Symbols.EQUALS -> evaluateEquals(left, right); case Symbols.NOT_EQUALS -> !evaluateEquals(left, right); - case Symbols.LESS_THAN -> evaluateComparison(left, right) < 0; - case Symbols.GREATER_THAN -> evaluateComparison(left, right) > 0; - case Symbols.LESS_THAN_EQUALS -> evaluateComparison(left, right) <= 0; - case Symbols.GREATER_THAN_EQUALS -> evaluateComparison(left, right) >= 0; + case Symbols.LESS_THAN -> evaluateComparison(node, left, right) < 0; + case Symbols.GREATER_THAN -> evaluateComparison(node, left, right) > 0; + case Symbols.LESS_THAN_EQUALS -> evaluateComparison(node, left, right) <= 0; + case Symbols.GREATER_THAN_EQUALS -> evaluateComparison(node, left, right) >= 0; case Symbols.AND -> toBoolean(left) && toBoolean(right); case Symbols.OR -> toBoolean(left) || toBoolean(right); case Symbols.IN -> evaluateIn(left, right); case Symbols.NOT_IN -> !evaluateIn(left, right); - default -> throw new RuntimeException("Unknown operator: " + node.operator()); + default -> throw new EvaluationException("Unknown operator", node); }; } @@ -205,7 +198,7 @@ private boolean evaluateEquals(Object left, Object right) { * @return Negative if left < right, 0 if left == right, positive if left > right */ @SuppressWarnings("unchecked") - private int evaluateComparison(Object left, Object right) { + private int evaluateComparison(Node node, Object left, Object right) { if (left == null && right == null) return 0; if (left == null) return -1; if (right == null) return 1; @@ -221,8 +214,8 @@ private int evaluateComparison(Object left, Object right) { return leftComp.compareTo(right); } - throw new RuntimeException("Cannot compare " + left.getClass().getSimpleName() + - " and " + right.getClass().getSimpleName()); + throw new EvaluationException("Cannot compare " + left.getClass().getSimpleName() + + " and " + right.getClass().getSimpleName(), node); } /** @@ -317,7 +310,7 @@ private String evaluateComponent(ComponentElementNode component) { var cachedComponent = components.get(componentDef); if (cachedComponent == null) - throw new RuntimeException("Component not found: " + componentDef); + throw new ComponentNotFoundException("Component not found", component, componentDef); // Attributes var context = new HashMap(); @@ -378,7 +371,6 @@ private String evaluateAttributeValueNode(AttributeValueNode attributeValueNode) /** * Parse inline attributes from evaluated expression content. - * Handles formats like: "--selected", "--name=value", "class=active" * * @param content The evaluated content containing attributes * @param context The context map to add attributes to @@ -444,45 +436,6 @@ private void parseInlineAttributes(String content, Map context) } } - // ===== Helpers ===== - - /** - * Convert an object to a boolean value. - * - * @param value The object to convert - * @return The boolean value - */ - private boolean toBoolean(Object value) { - return switch (value) { - case null -> false; - case Boolean b -> b; - case Number n -> n.doubleValue() != 0; - case String s -> !s.isEmpty(); - case Collection c -> !c.isEmpty(); - case Map m -> !m.isEmpty(); - default -> true; - }; - } - - /** - * Convert an object to an iterable or throw an exception. - * - * @param value The object to convert - * @return The iterable - */ - private Iterable toIterable(Object value) { - if (value instanceof Iterable iterable) - return iterable; - - if (value instanceof Map map) - return map.entrySet(); - - if (value.getClass().isArray()) - return Arrays.asList((Object[]) value); - - throw new RuntimeException("Cannot iterate over " + value.getClass()); - } - /** * Evaluate if needle is in haystack. * diff --git a/src/main/java/au/ellie/hyui/html/template/Lexer.java b/src/main/java/au/ellie/hyui/html/template/Lexer.java index 9d368f9..8775e7f 100644 --- a/src/main/java/au/ellie/hyui/html/template/Lexer.java +++ b/src/main/java/au/ellie/hyui/html/template/Lexer.java @@ -306,7 +306,7 @@ private boolean tokenizeStartComponent(List tokens) { // Attributes while (pos < input.length() && !peek(COMPONENT_END, COMPONENT_SELF_CLOSE)) { // Attribute name - if (peek("--") || Character.isLetter(current())) + if (Character.isLetter(current())) tokens.add(tokenizeComponentAttributeName()); skipWhitespace(); diff --git a/src/main/java/au/ellie/hyui/html/template/Parser.java b/src/main/java/au/ellie/hyui/html/template/Parser.java index 6c94c75..7c97968 100644 --- a/src/main/java/au/ellie/hyui/html/template/Parser.java +++ b/src/main/java/au/ellie/hyui/html/template/Parser.java @@ -18,7 +18,7 @@ package au.ellie.hyui.html.template; -import au.ellie.hyui.html.TemplateProcessor.CachedComponent; +import au.ellie.hyui.html.template.context.ParserException; import au.ellie.hyui.html.template.item.Node; import au.ellie.hyui.html.template.item.Node.AttributeValueNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; @@ -29,13 +29,7 @@ import au.ellie.hyui.html.template.item.Node.BlockNode.IfBlockNode; import au.ellie.hyui.html.template.item.Node.ComponentElementNode; import au.ellie.hyui.html.template.item.Node.ExpressionNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.BinaryOpNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.DefaultNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.LiteralNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.PipeNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.PropertyAccessNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.TextNode; -import au.ellie.hyui.html.template.item.Node.ExpressionNode.VariableNode; +import au.ellie.hyui.html.template.item.Node.ExpressionNode.*; import au.ellie.hyui.html.template.item.Symbols; import au.ellie.hyui.html.template.item.Token; import au.ellie.hyui.html.template.item.Token.Type; @@ -43,19 +37,16 @@ import java.util.ArrayList; import java.util.LinkedList; import java.util.List; -import java.util.Map; import java.util.Stack; import static au.ellie.hyui.html.template.item.Token.Type.*; public class Parser { - private final Map components; private final Stack context; private final List tokens; private int pos = 0; - public Parser(List tokens, Map componentCache) { - this.components = componentCache; + public Parser(List tokens) { this.context = new Stack<>(); this.tokens = tokens; } @@ -90,7 +81,7 @@ private Node parseNode() { case EXPRESSION_OPEN -> parseExpressionOrBlock(); case COMPONENT_OPEN -> parseComponentElement(); case ATTRIBUTE -> parseAttribute(); - default -> throw new RuntimeException("Unexpected token: " + token); + default -> throw new ParserException("Unexpected token", token, pos); }; } @@ -142,7 +133,7 @@ private ExpressionNode parsePrimary() { return expr; } - throw new RuntimeException("Unexpected token in expression: " + current()); + throw new ParserException("Unexpected token in expression", current(), pos); } // ===== Logical Expression ===== @@ -267,8 +258,7 @@ private Node parseBlock() { return switch (token.value()) { case Symbols.SECTION_IF -> parseIfBlock(); case Symbols.SECTION_EACH -> parseEachBlock(); - default -> - throw new RuntimeException("Unknown block value \"" + token.value() + "\" for token " + token.type()); + default -> throw new ParserException("Unknown token for block", token, pos); }; } @@ -376,7 +366,7 @@ private ComponentElementNode parseComponentElement() { private Node parseAttribute() { var context = this.context.peek(); if (context == null) - throw new RuntimeException("No HTML tag context for attribute at position " + pos); + throw new ParserException("No HTML tag context for attribute", current(), pos); var attribute = get(ATTRIBUTE); if (attribute != null) { @@ -396,11 +386,11 @@ private Node parseAttribute() { return new DynamicAttributeNode(name, expr); } - throw new RuntimeException("Expected attribute value at position " + current().position()); + throw new ParserException("Expected attribute value", current(), pos); } else if (peek(EXPRESSION_OPEN)) return new ExpressionAttributeNode(parseExpressionOrBlock()); - throw new RuntimeException("Unexpected token in tag <" + context.value() + ">: " + current()); + throw new ParserException("Unexpected token in tag <" + context.value() + ">", current(), pos); } /** @@ -425,7 +415,8 @@ private List parseHtmlChildren(String parentTag) { children.add(child); } - throw new RuntimeException("Unclosed tag: " + parentTag + " at position " + tokens.getLast().position()); + var last = tokens.getLast(); + throw new ParserException("Unclosed tag <" + parentTag + ">", last, last.position()); } // ===== Helpers ===== @@ -506,7 +497,7 @@ private Token get(Type type, String... values) { /** * Except the token to match the given type and any of the given values, consuming it. * - * @throws RuntimeException if the expected type or value do not match + * @throws ParserException if the expected type or value do not match */ private Token expect(Type type, String... values) { var token = current(); @@ -515,7 +506,7 @@ private Token expect(Type type, String... values) { return token; } - throw new RuntimeException("Expected " + type + (values.length > 0 ? " with value \"" + String.join("/", values) + "\"" : "") + " but got " + current().type() + " with value " + current().value() + " at index " + pos); + throw new ParserException("Expected " + type + (values.length > 0 ? " with value \"" + String.join("/", values) + "\"" : ""), token, pos); } /** diff --git a/src/main/java/au/ellie/hyui/html/template/context/EvaluationException.java b/src/main/java/au/ellie/hyui/html/template/context/EvaluationException.java new file mode 100644 index 0000000..3ec9e89 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/EvaluationException.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.template.item.Node; + +public class EvaluationException extends RuntimeException { + + /** + * The node in cause at the time of the exception + */ + public final Node node; + + public EvaluationException(String message, Node node) { + super(message); + this.node = node; + } + + /** + * Exception thrown when a component is not found in the context during evaluation + */ + public static class ComponentNotFoundException extends EvaluationException { + + /** + * The tag of the component that was found + */ + public final String tag; + + public ComponentNotFoundException(String message, Node node, String tag) { + super(message, node); + this.tag = tag; + } + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java b/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java index e957b2e..4db1909 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java +++ b/src/main/java/au/ellie/hyui/html/template/context/FilterRegistry.java @@ -18,6 +18,9 @@ package au.ellie.hyui.html.template.context; +import au.ellie.hyui.utils.ParseUtils; +import au.ellie.hyui.utils.StringUtils; + import java.util.Collection; import java.util.HashMap; import java.util.Locale; @@ -30,16 +33,8 @@ public class FilterRegistry { public FilterRegistry() { register("uppercase", value -> value == null ? null : value.toString().toUpperCase(), "upper"); register("lowercase", value -> value == null ? null : value.toString().toLowerCase(), "lower"); - register("capitalize", value -> { - if (value == null) - return null; - - String str = value.toString(); - if (str.isEmpty()) - return str; - - return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase(); - }); + register("capitalize", value -> StringUtils.capitalize(value.toString())); + register("capitalizeAll", value -> StringUtils.capitalizeAll(value.toString())); register("trim", value -> value == null ? null : value.toString().trim()); register("length", value -> switch (value) { case null -> 0; @@ -49,24 +44,22 @@ public FilterRegistry() { default -> value.toString().length(); }); register("number", value -> { - try { - double num = Double.parseDouble(value.toString()); - if (num == (long) num) - return String.format(Locale.ENGLISH, "%,d", (long) num); - - return String.format("%,.2f", num); - } catch (NumberFormatException e) { + var num = ParseUtils.parseDouble(value.toString()); + if (num.isEmpty()) return value; - } + + var numValue = num.get(); + if (numValue % 1 == 0) + return String.format(Locale.ENGLISH, "%,d", numValue.longValue()); + + return String.format("%,.2f", numValue); }); register("percent", value -> { - try { - double num = Double.parseDouble(value.toString()); - - return String.format(Locale.ENGLISH, "%.0f%%", num * 100); - } catch (NumberFormatException e) { + var num = ParseUtils.parseDouble(value.toString()); + if (num.isEmpty()) return value; - } + + return String.format(Locale.ENGLISH, "%.0f%%", num.get() * 100); }); } diff --git a/src/main/java/au/ellie/hyui/html/template/context/ParserException.java b/src/main/java/au/ellie/hyui/html/template/context/ParserException.java new file mode 100644 index 0000000..aa4c460 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/ParserException.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.template.item.Token; + +public class ParserException extends RuntimeException { + + /** + * The token in cause at the time of the exception + */ + public final Token token; + + /** + * The index of the token in the original template string + */ + public final int index; + + public ParserException(String message, Token token, int index) { + super(message); + this.token = token; + this.index = index; + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java index e814268..c39bfe4 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java @@ -19,7 +19,7 @@ package au.ellie.hyui.html.template.context; import au.ellie.hyui.html.TemplateProcessor.ValueResolver; -import au.ellie.hyui.html.template.utils.LambdaUtils; +import au.ellie.hyui.utils.LambdaUtils; import javax.annotation.Nonnull; import javax.annotation.Nullable; diff --git a/src/main/java/au/ellie/hyui/html/template/utils/LambdaUtils.java b/src/main/java/au/ellie/hyui/utils/LambdaUtils.java similarity index 99% rename from src/main/java/au/ellie/hyui/html/template/utils/LambdaUtils.java rename to src/main/java/au/ellie/hyui/utils/LambdaUtils.java index b9d773d..30ad02e 100644 --- a/src/main/java/au/ellie/hyui/html/template/utils/LambdaUtils.java +++ b/src/main/java/au/ellie/hyui/utils/LambdaUtils.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.template.utils; +package au.ellie.hyui.utils; import java.lang.reflect.Method; import java.lang.reflect.Modifier; diff --git a/src/main/java/au/ellie/hyui/html/template/utils/NumericUtils.java b/src/main/java/au/ellie/hyui/utils/NumericUtils.java similarity index 98% rename from src/main/java/au/ellie/hyui/html/template/utils/NumericUtils.java rename to src/main/java/au/ellie/hyui/utils/NumericUtils.java index 979ea96..876caf9 100644 --- a/src/main/java/au/ellie/hyui/html/template/utils/NumericUtils.java +++ b/src/main/java/au/ellie/hyui/utils/NumericUtils.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.template.utils; +package au.ellie.hyui.utils; import javax.annotation.Nullable; diff --git a/src/main/java/au/ellie/hyui/utils/ObjectUtils.java b/src/main/java/au/ellie/hyui/utils/ObjectUtils.java new file mode 100644 index 0000000..09070bf --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/ObjectUtils.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; + +public class ObjectUtils { + + /** + * Convert an object to a boolean value. + * + * @param value The object to convert + * @return The boolean value + */ + public static boolean toBoolean(Object value) { + return switch (value) { + case null -> false; + case Boolean b -> b; + case Number n -> n.doubleValue() != 0; + case String s -> !s.isEmpty(); + case Collection c -> !c.isEmpty(); + case Map m -> !m.isEmpty(); + default -> true; + }; + } + + /** + * Convert an object to an iterable or throw an exception. + * + * @param value The object to convert + * @return The iterable + */ + public static Iterable toIterable(Object value) { + if (value instanceof Iterable iterable) + return iterable; + + if (value instanceof Map map) + return map.entrySet(); + + if (value.getClass().isArray()) + return Arrays.asList((Object[]) value); + + throw new RuntimeException("Cannot iterate over " + value.getClass()); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java b/src/main/java/au/ellie/hyui/utils/ReflectionUtils.java similarity index 99% rename from src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java rename to src/main/java/au/ellie/hyui/utils/ReflectionUtils.java index 9364bfa..30366f1 100644 --- a/src/main/java/au/ellie/hyui/html/template/utils/ReflectionUtils.java +++ b/src/main/java/au/ellie/hyui/utils/ReflectionUtils.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.template.utils; +package au.ellie.hyui.utils; import java.lang.reflect.Method; import java.lang.reflect.Modifier; diff --git a/src/main/java/au/ellie/hyui/utils/StringUtils.java b/src/main/java/au/ellie/hyui/utils/StringUtils.java new file mode 100644 index 0000000..3c4f818 --- /dev/null +++ b/src/main/java/au/ellie/hyui/utils/StringUtils.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.utils; + +/** + * Utility class for string manipulation. + *

    + * Used instead of Hytale's StringUtils to support space + * and dot as word separators in capitalizeAll. + */ +public class StringUtils { + + /** + * Capitalize the first letter of the string. + * + * @param str The string to capitalize + */ + public static String capitalize(String str) { + if (str == null || str.isEmpty()) + return str; + + return capitalizeUnsafe(str); + } + + /** + * Capitalize the first letter of each word in the string. + * + * @param str The string to capitalize + */ + public static String capitalizeAll(String str) { + if (str == null || str.isEmpty()) + return str; + + String[] words = str.split("[\\s\\.]+"); + StringBuilder result = new StringBuilder(); + + for (String word : words) { + if (word.isEmpty()) + continue; + + result.append(capitalizeUnsafe(word)).append(" "); + } + + return result.toString().trim(); + } + + /** + * Capitalize the first letter of the string + * without checking for null or empty. + * + * @param str The string to capitalize + */ + private static String capitalizeUnsafe(String str) { + return str.substring(0, 1).toUpperCase() + str.substring(1); + } +} diff --git a/src/test/java/au/ellie/hyui/html/template/utils/NumericUtilsTest.java b/src/test/java/au/ellie/hyui/html/template/utils/NumericUtilsTest.java index cb61b02..4e34cc7 100644 --- a/src/test/java/au/ellie/hyui/html/template/utils/NumericUtilsTest.java +++ b/src/test/java/au/ellie/hyui/html/template/utils/NumericUtilsTest.java @@ -1,5 +1,6 @@ package au.ellie.hyui.html.template.utils; +import au.ellie.hyui.utils.NumericUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From 7d4fc9ecab34cbe23e4c084cf650d5e011a5ebd2 Mon Sep 17 00:00:00 2001 From: Farrael Date: Mon, 9 Feb 2026 02:27:51 +0100 Subject: [PATCH 09/30] feat: add caching policy for variables in template --- .../au/ellie/hyui/html/TemplateProcessor.java | 78 ++++++++++++------- .../ellie/hyui/html/template/Evaluator.java | 4 +- .../au/ellie/hyui/html/template/Parser.java | 2 +- .../template/context/VariableHandler.java | 43 ++++++++++ .../html/template/context/VariableStack.java | 31 +++++--- .../EvaluationException.java | 2 +- .../ParserException.java | 2 +- .../java/au/ellie/hyui/utils/LambdaUtils.java | 7 +- .../hyui/html/TemplateProcessorTest.java | 12 +-- 9 files changed, 127 insertions(+), 54 deletions(-) create mode 100644 src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java rename src/main/java/au/ellie/hyui/html/template/{context => exception}/EvaluationException.java (97%) rename src/main/java/au/ellie/hyui/html/template/{context => exception}/ParserException.java (96%) diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index 95d5bf7..8fc2c5c 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -24,6 +24,7 @@ import au.ellie.hyui.html.template.Lexer; import au.ellie.hyui.html.template.Parser; import au.ellie.hyui.html.template.context.FilterRegistry; +import au.ellie.hyui.html.template.context.VariableHandler.CachingVariableHandler; import au.ellie.hyui.html.template.context.VariableStack; import au.ellie.hyui.html.template.context.VariableStack.VariableScope; import au.ellie.hyui.html.template.item.Node; @@ -37,11 +38,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.function.Function; import java.util.function.Supplier; @@ -99,43 +96,53 @@ public TemplateProcessor setTemplate(String template) { } /** - * Sets a template variable from any object. + * Registers a template variable backed by an arbitrary object. + *

    + * The provided value is lazily resolved and cached on first access. + * This is useful for deferring expensive computations until the variable + * is actually used during template evaluation. * - * @param name Variable name (without $) - * @param value Variable value (will be converted to string) - * @return This processor for chaining + * @param name Variable name (without the '$' prefix) + * @param value Variable value (will be resolved and converted to a string) + * @return This {@link TemplateProcessor} instance, allowing method chaining */ public TemplateProcessor setVariable(String name, Object value) { - if (value instanceof Function) - throw new RuntimeException("Use the Function overload to set a variable from a function."); - - variables.put(name, value); + variables.put(name, new CachingVariableHandler(value)); return this; } /** - * Sets a template variable from a supplier. - * Resolved at processing time and cached for the duration of the processing. + * Registers a template variable whose value is provided lazily via a {@link Supplier}. + *

    + * The value can be cached according to the provided {@link CachePolicy}. If the policy is + * {@link CachePolicy#DYNAMIC}, the supplier is evaluated on every access. Otherwise, the value + * is computed once and cached. * - * @param name Variable name (without $) - * @param value Supplier that provides the variable value - * @return This processor for chaining + * @param name The variable name (without the '$' prefix) + * @param value A {@link Supplier} that provides the variable's value + * @param cachePolicy The caching strategy to apply for this variable + * @return This {@link TemplateProcessor} instance, allowing method chaining */ - public TemplateProcessor setVariable(String name, Supplier value) { - variables.put(name, value); + public TemplateProcessor setVariable(String name, Supplier value, CachePolicy cachePolicy) { + variables.put(name, cachePolicy == CachePolicy.DYNAMIC ? value : new CachingVariableHandler(value)); return this; } /** - * Sets a template variable from a function. - * Resolved at processing time with access to the variable stack, not cached. + * Registers a template variable whose value is provided lazily via a {@link Function} that + * receives the current {@link VariableStack}. + *

    + * The value can be cached according to the provided {@link CachePolicy}. If the policy is + * {@link CachePolicy#DYNAMIC}, the function is evaluated on every access. Otherwise, the value + * is computed once and cached. * - * @param name Variable name (without $) - * @param value Function that provides the variable value - * @return This processor for chaining + * @param name The variable name (without the '$' prefix) + * @param value A {@link Function} that computes the variable's value based on the current {@link VariableStack} + * @param cachePolicy The caching strategy to apply for this variable + * @return This {@link TemplateProcessor} instance, allowing method chaining */ - public TemplateProcessor setVariable(String name, Function value) { - variables.put(name, value); + public TemplateProcessor setVariable(String name, Function value, CachePolicy cachePolicy) { + variables.put(name, cachePolicy == CachePolicy.DYNAMIC ? value : new CachingVariableHandler(value)); return this; } @@ -148,6 +155,7 @@ public TemplateProcessor setVariable(String name, Function vars) { for (Map.Entry entry : vars.entrySet()) setVariable(entry.getKey(), entry.getValue()); + return this; } @@ -381,4 +389,20 @@ public List getAst(Map componentCache) { return ast; } } + + /** + * Caching policy for variable. + */ + public enum CachePolicy { + /** + * No caching. + * The value with be evaluated on every request. + */ + DYNAMIC, + + /** + * Cache the value after the first evaluation. + */ + CACHED + } } diff --git a/src/main/java/au/ellie/hyui/html/template/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java index 14c6091..01ec940 100644 --- a/src/main/java/au/ellie/hyui/html/template/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -20,11 +20,11 @@ import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.html.TemplateProcessor.CachedComponent; -import au.ellie.hyui.html.template.context.EvaluationException; -import au.ellie.hyui.html.template.context.EvaluationException.ComponentNotFoundException; import au.ellie.hyui.html.template.context.FilterRegistry; import au.ellie.hyui.html.template.context.VariableStack; import au.ellie.hyui.html.template.context.VariableStack.VariableScope; +import au.ellie.hyui.html.template.exception.EvaluationException; +import au.ellie.hyui.html.template.exception.EvaluationException.ComponentNotFoundException; import au.ellie.hyui.html.template.item.Node; import au.ellie.hyui.html.template.item.Node.AttributeValueNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; diff --git a/src/main/java/au/ellie/hyui/html/template/Parser.java b/src/main/java/au/ellie/hyui/html/template/Parser.java index 7c97968..5097367 100644 --- a/src/main/java/au/ellie/hyui/html/template/Parser.java +++ b/src/main/java/au/ellie/hyui/html/template/Parser.java @@ -18,7 +18,7 @@ package au.ellie.hyui.html.template; -import au.ellie.hyui.html.template.context.ParserException; +import au.ellie.hyui.html.template.exception.ParserException; import au.ellie.hyui.html.template.item.Node; import au.ellie.hyui.html.template.item.Node.AttributeValueNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java b/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java new file mode 100644 index 0000000..663e242 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java @@ -0,0 +1,43 @@ +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.template.context.VariableStack.VariableScope; + +public interface VariableHandler { + + /** + * Retrieve the variable stored in the handler + */ + Object get(); + + /** + * Process the value after transformation, + * allowing for custom handling of the variable. + * + * @param key The key associated with the variable + * @param value The stored value after transformation + * @param scope The {@link VariableScope} used to retrieve the variable + */ + void handle(String key, Object value, VariableScope scope); + + /** + * A simple implementation of VariableHandler that caches the value. + * This handle simply stores the value in the {@link VariableScope} without any additional processing. + */ + class CachingVariableHandler implements VariableHandler { + private final Object cachedValue; + + public CachingVariableHandler(Object value) { + this.cachedValue = value; + } + + @Override + public Object get() { + return cachedValue; + } + + @Override + public void handle(String key, Object value, VariableScope scope) { + scope.put(key, value); + } + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java index c39bfe4..6798cc4 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java @@ -23,12 +23,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.ArrayDeque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.function.Supplier; public class VariableStack { @@ -96,11 +91,11 @@ public Object getVariable(String name, Supplier defaultValue) { if (object == null) return null; - if (object instanceof Supplier supplier) { - object = supplier.get(); - scope.put(name, object); - } else if (LambdaUtils.isFunction(object)) - object = LambdaUtils.call(object, this, defaultValue); + if (object instanceof VariableHandler handler) { + object = resolveVariable(handler.get(), defaultValue); + handler.handle(name, object, scope); + } else + object = resolveVariable(object, defaultValue); return object; } @@ -148,6 +143,20 @@ public Set getScopeKeys() { return scope != null ? scope.getKeys() : null; } + /** + * Resolve a variable that may be a function or supplier. + * + * @param object The variable value to resolve + * @param defaultValue Default value supplier for function calls + * @return Resolved variable value + */ + private Object resolveVariable(Object object, Supplier defaultValue) { + if (LambdaUtils.isFunction(object)) + object = LambdaUtils.call(object, this, defaultValue); + + return object; + } + // ===== Scope ===== public static class VariableScope { diff --git a/src/main/java/au/ellie/hyui/html/template/context/EvaluationException.java b/src/main/java/au/ellie/hyui/html/template/exception/EvaluationException.java similarity index 97% rename from src/main/java/au/ellie/hyui/html/template/context/EvaluationException.java rename to src/main/java/au/ellie/hyui/html/template/exception/EvaluationException.java index 3ec9e89..56730ce 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/EvaluationException.java +++ b/src/main/java/au/ellie/hyui/html/template/exception/EvaluationException.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.template.context; +package au.ellie.hyui.html.template.exception; import au.ellie.hyui.html.template.item.Node; diff --git a/src/main/java/au/ellie/hyui/html/template/context/ParserException.java b/src/main/java/au/ellie/hyui/html/template/exception/ParserException.java similarity index 96% rename from src/main/java/au/ellie/hyui/html/template/context/ParserException.java rename to src/main/java/au/ellie/hyui/html/template/exception/ParserException.java index aa4c460..51f3271 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/ParserException.java +++ b/src/main/java/au/ellie/hyui/html/template/exception/ParserException.java @@ -16,7 +16,7 @@ * */ -package au.ellie.hyui.html.template.context; +package au.ellie.hyui.html.template.exception; import au.ellie.hyui.html.template.item.Token; diff --git a/src/main/java/au/ellie/hyui/utils/LambdaUtils.java b/src/main/java/au/ellie/hyui/utils/LambdaUtils.java index 30ad02e..88b7939 100644 --- a/src/main/java/au/ellie/hyui/utils/LambdaUtils.java +++ b/src/main/java/au/ellie/hyui/utils/LambdaUtils.java @@ -21,12 +21,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.concurrent.Callable; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.function.Supplier; +import java.util.function.*; /** * Quick utility for detecting and calling multiple type of lambda/function objects, diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index fb0ea74..8e8697d 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -13,6 +13,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import static au.ellie.hyui.html.TemplateProcessor.CachePolicy.CACHED; +import static au.ellie.hyui.html.TemplateProcessor.CachePolicy.DYNAMIC; import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.EACH_SCOPE_NAME; import static org.junit.jupiter.api.Assertions.*; @@ -224,7 +226,7 @@ void supplierEvaluationOnIfCondition(boolean condition, int value, String expect processor.setVariable("secret", () -> { evaluations.incrementAndGet(); return "value_" + evaluations; - }); + }, CACHED); processor.setTemplate(""" {{#if $enabled}} @@ -248,7 +250,7 @@ void functionEvaluationOnIfCondition(boolean condition, int value, String expect processor.setVariable("secret", (_) -> { evaluations.incrementAndGet(); return "value_" + evaluations; - }); + }, DYNAMIC); processor.setTemplate(""" {{#if $enabled}} @@ -798,7 +800,7 @@ void callFunctionWithArguments() { return ""; return product.getTagsWithPrefix("tag_"); - }); + }, DYNAMIC); processor.setTemplate(normalize(""" {{#each $products product}} @@ -818,8 +820,8 @@ void callFunctionWithArgumentsAndDynamic() { final var modulator = new Modulator(); processor.setVariable("list", List.of(1, 2, 3, 4)); - processor.setVariable("modulation", (_) -> modulator.increment()); - processor.setVariable("style", (stack) -> (int) stack.getVariable("key") < 3 ? "color: red;" : null); + processor.setVariable("modulation", (_) -> modulator.increment(), DYNAMIC); + processor.setVariable("style", (stack) -> (int) stack.getVariable("key") < 3 ? "color: red;" : null, DYNAMIC); processor.registerComponent("module", """ Module {{$key}} -> Active : {{ $active ?? false }} From e208876e9883ae010bed0b1dfddf79feb3105d2c Mon Sep 17 00:00:00 2001 From: Farrael Date: Mon, 9 Feb 2026 12:49:57 +0100 Subject: [PATCH 10/30] feat: right variable not always evaluated `and` / new policies for variable --- .../au/ellie/hyui/html/TemplateProcessor.java | 85 ++++++++++++------- .../ellie/hyui/html/template/Evaluator.java | 22 ++--- .../template/context/VariableHandler.java | 46 ++++++++++ .../html/template/context/VariableStack.java | 5 ++ .../hyui/html/TemplateProcessorTest.java | 60 ++++++++++--- 5 files changed, 167 insertions(+), 51 deletions(-) diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index 8fc2c5c..3c856f1 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -25,6 +25,8 @@ import au.ellie.hyui.html.template.Parser; import au.ellie.hyui.html.template.context.FilterRegistry; import au.ellie.hyui.html.template.context.VariableHandler.CachingVariableHandler; +import au.ellie.hyui.html.template.context.VariableHandler.EphemeralVariableHandler; +import au.ellie.hyui.html.template.context.VariableHandler.NonNullVariableHandler; import au.ellie.hyui.html.template.context.VariableStack; import au.ellie.hyui.html.template.context.VariableStack.VariableScope; import au.ellie.hyui.html.template.item.Node; @@ -98,52 +100,63 @@ public TemplateProcessor setTemplate(String template) { /** * Registers a template variable backed by an arbitrary object. *

    - * The provided value is lazily resolved and cached on first access. - * This is useful for deferring expensive computations until the variable - * is actually used during template evaluation. * * @param name Variable name (without the '$' prefix) - * @param value Variable value (will be resolved and converted to a string) + * @param value Variable value * @return This {@link TemplateProcessor} instance, allowing method chaining */ public TemplateProcessor setVariable(String name, Object value) { - variables.put(name, new CachingVariableHandler(value)); - return this; + return setVariable(name, value, ExecutionPolicy.CACHED); } /** - * Registers a template variable whose value is provided lazily via a {@link Supplier}. + * Registers a template variable backed by an arbitrary object. *

    - * The value can be cached according to the provided {@link CachePolicy}. If the policy is - * {@link CachePolicy#DYNAMIC}, the supplier is evaluated on every access. Otherwise, the value - * is computed once and cached. + * The behavior of the evaluation is controlled by the provided {@link ExecutionPolicy}. * - * @param name The variable name (without the '$' prefix) - * @param value A {@link Supplier} that provides the variable's value - * @param cachePolicy The caching strategy to apply for this variable + * @param name The variable name (without the '$' prefix) + * @param value Variable value + * @param policy The execution policy controlling how the value is evaluated and retained * @return This {@link TemplateProcessor} instance, allowing method chaining */ - public TemplateProcessor setVariable(String name, Supplier value, CachePolicy cachePolicy) { - variables.put(name, cachePolicy == CachePolicy.DYNAMIC ? value : new CachingVariableHandler(value)); + public TemplateProcessor setVariable(String name, Object value, ExecutionPolicy policy) { + var result = switch (policy) { + case CACHED -> new CachingVariableHandler(value); + case NON_NULL -> new NonNullVariableHandler(value); + case EPHEMERAL -> new EphemeralVariableHandler(value); + default -> value; + }; + + variables.put(name, result); return this; } /** - * Registers a template variable whose value is provided lazily via a {@link Function} that - * receives the current {@link VariableStack}. + * Registers a template variable whose value is evaluated lazily using a {@link Supplier}. *

    - * The value can be cached according to the provided {@link CachePolicy}. If the policy is - * {@link CachePolicy#DYNAMIC}, the function is evaluated on every access. Otherwise, the value - * is computed once and cached. + * The behavior of the evaluation is controlled by the provided {@link ExecutionPolicy}. * - * @param name The variable name (without the '$' prefix) - * @param value A {@link Function} that computes the variable's value based on the current {@link VariableStack} - * @param cachePolicy The caching strategy to apply for this variable + * @param name The variable name (without the '$' prefix) + * @param value A {@link Supplier} that provides the variable's value + * @param policy The execution policy controlling how the value is evaluated and retained * @return This {@link TemplateProcessor} instance, allowing method chaining */ - public TemplateProcessor setVariable(String name, Function value, CachePolicy cachePolicy) { - variables.put(name, cachePolicy == CachePolicy.DYNAMIC ? value : new CachingVariableHandler(value)); - return this; + public TemplateProcessor setVariable(String name, Supplier value, ExecutionPolicy policy) { + return setVariable(name, (Object) value, policy); + } + + /** + * Registers a template variable whose value is evaluated lazily using a {@link Function}. + *

    + * The behavior of the evaluation is controlled by the provided {@link ExecutionPolicy}. + * + * @param name The variable name (without the '$' prefix) + * @param value A {@link Function} that provides the variable's value + * @param policy The execution policy controlling how the value is evaluated and retained + * @return This {@link TemplateProcessor} instance, allowing method chaining + */ + public TemplateProcessor setVariable(String name, Function value, ExecutionPolicy policy) { + return setVariable(name, (Object) value, policy); } /** @@ -154,7 +167,7 @@ public TemplateProcessor setVariable(String name, Function vars) { for (Map.Entry entry : vars.entrySet()) - setVariable(entry.getKey(), entry.getValue()); + setVariable(entry.getKey(), entry.getValue(), ExecutionPolicy.CACHED); return this; } @@ -391,9 +404,9 @@ public List getAst(Map componentCache) { } /** - * Caching policy for variable. + * Execution policy for lazily evaluated variables. */ - public enum CachePolicy { + public enum ExecutionPolicy { /** * No caching. * The value with be evaluated on every request. @@ -403,6 +416,18 @@ public enum CachePolicy { /** * Cache the value after the first evaluation. */ - CACHED + CACHED, + + /** + * Evaluate the value only once, deleting it afterward. + * This is useful for one-time action. + */ + EPHEMERAL, + + /** + * Evaluate the value until the first null result, + * then delete it from the stack. + */ + NON_NULL } } diff --git a/src/main/java/au/ellie/hyui/html/template/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java index 01ec940..5286033 100644 --- a/src/main/java/au/ellie/hyui/html/template/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -152,20 +152,20 @@ private Object evaluatePropertyAccess(PropertyAccessNode node) { * @return The result of the binary operation. */ private Object evaluateBinaryOp(BinaryOpNode node) { + Supplier right = () -> evaluateExpression(node.right()); var left = evaluateExpression(node.left()); - var right = evaluateExpression(node.right()); return switch (node.operator()) { - case Symbols.EQUALS -> evaluateEquals(left, right); - case Symbols.NOT_EQUALS -> !evaluateEquals(left, right); - case Symbols.LESS_THAN -> evaluateComparison(node, left, right) < 0; - case Symbols.GREATER_THAN -> evaluateComparison(node, left, right) > 0; - case Symbols.LESS_THAN_EQUALS -> evaluateComparison(node, left, right) <= 0; - case Symbols.GREATER_THAN_EQUALS -> evaluateComparison(node, left, right) >= 0; - case Symbols.AND -> toBoolean(left) && toBoolean(right); - case Symbols.OR -> toBoolean(left) || toBoolean(right); - case Symbols.IN -> evaluateIn(left, right); - case Symbols.NOT_IN -> !evaluateIn(left, right); + case Symbols.EQUALS -> evaluateEquals(left, right.get()); + case Symbols.NOT_EQUALS -> !evaluateEquals(left, right.get()); + case Symbols.LESS_THAN -> evaluateComparison(node, left, right.get()) < 0; + case Symbols.GREATER_THAN -> evaluateComparison(node, left, right.get()) > 0; + case Symbols.LESS_THAN_EQUALS -> evaluateComparison(node, left, right.get()) <= 0; + case Symbols.GREATER_THAN_EQUALS -> evaluateComparison(node, left, right.get()) >= 0; + case Symbols.AND -> toBoolean(left) && toBoolean(right.get()); + case Symbols.OR -> toBoolean(left) || toBoolean(right.get()); + case Symbols.IN -> evaluateIn(left, right.get()); + case Symbols.NOT_IN -> !evaluateIn(left, right.get()); default -> throw new EvaluationException("Unknown operator", node); }; } diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java b/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java index 663e242..df0a89a 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableHandler.java @@ -40,4 +40,50 @@ public void handle(String key, Object value, VariableScope scope) { scope.put(key, value); } } + + /** + * A simple implementation of VariableHandler that delete the variable if the value is null. + * This handle remove the key from the {@link VariableScope} if the value is null, otherwise it does nothing. + */ + class NonNullVariableHandler implements VariableHandler { + private final Object cachedValue; + + public NonNullVariableHandler(Object value) { + this.cachedValue = value; + } + + @Override + public Object get() { + return cachedValue; + } + + @Override + public void handle(String key, Object value, VariableScope scope) { + if (value == null) + scope.remove(key); + } + } + + /** + * A simple implementation of VariableHandler that delete the variable after the first access. + * This handle remove the key from the {@link VariableScope} after the first retrieval of the variable, + * allowing for one-time use variables. + */ + class EphemeralVariableHandler implements VariableHandler { + private final Object cachedValue; + + public EphemeralVariableHandler(Object value) { + this.cachedValue = value; + } + + @Override + public Object get() { + return cachedValue; + } + + @Override + public void handle(String key, Object value, VariableScope scope) { + scope.remove(key); + } + } } diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java index 6798cc4..3745029 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java @@ -207,6 +207,11 @@ public void put(String key, Object value) { content.put(key, value); } + public void remove(String key) { + content.remove(key); + keys.remove(key); + } + public void putKeyed(String key, Object value) { content.put(key, value); keys.add(key); diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index 8e8697d..2d32c99 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -13,8 +13,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import static au.ellie.hyui.html.TemplateProcessor.CachePolicy.CACHED; -import static au.ellie.hyui.html.TemplateProcessor.CachePolicy.DYNAMIC; +import static au.ellie.hyui.html.TemplateProcessor.ExecutionPolicy.*; import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.EACH_SCOPE_NAME; import static org.junit.jupiter.api.Assertions.*; @@ -218,8 +217,8 @@ void missingProperties() { "false, 0, ", "true, 1, value_1 - value_1" }) - @DisplayName("Should handle supplier properties") - void supplierEvaluationOnIfCondition(boolean condition, int value, String expected) { + @DisplayName("Should ensure the variable is evaluated only once and cached") + void policyCachedEvaluation(boolean condition, int value, String expected) { AtomicInteger evaluations = new AtomicInteger(); processor.setVariable("enabled", condition); @@ -242,15 +241,12 @@ void supplierEvaluationOnIfCondition(boolean condition, int value, String expect "false, 0, ", "true, 2, value_1 - value_2" }) - @DisplayName("Should handle function properties") - void functionEvaluationOnIfCondition(boolean condition, int value, String expected) { + @DisplayName("Should ensure the variable is evaluated every time it's accessed") + void policyDynamicEvaluation(boolean condition, int value, String expected) { AtomicInteger evaluations = new AtomicInteger(); processor.setVariable("enabled", condition); - processor.setVariable("secret", (_) -> { - evaluations.incrementAndGet(); - return "value_" + evaluations; - }, DYNAMIC); + processor.setVariable("secret", (_) -> "value_" + evaluations.incrementAndGet(), DYNAMIC); processor.setTemplate(""" {{#if $enabled}} @@ -260,6 +256,50 @@ void functionEvaluationOnIfCondition(boolean condition, int value, String expect assertEquals(expected != null ? expected : "", processor.process()); assertEquals(value, evaluations.get()); } + + @ParameterizedTest + @CsvSource({ + "false, secret value", + "true, secret already revealed: disappeared" + }) + @DisplayName("Should ensure the variable is evaluated only once and then removed") + void policyEphemeralEvaluation(boolean condition, String expected) { + processor.setVariable("condition", condition); + processor.setVariable("secret", (_) -> "secret value", EPHEMERAL); + + processor.setTemplate(""" + {{#if $condition && $secret}}{{/if}} + {{$secret ?? "secret already revealed: disappeared"}} + """); + assertEquals(expected, processor.process()); + } + + @Test + @DisplayName("Should ensure the variable is evaluated until it returns null, then removed") + void policyNonNullEvaluation() { + AtomicInteger evaluations = new AtomicInteger(); + + processor.setVariable("loop", List.of(1, 2, 3, 4)); + processor.setVariable("secret", (_) -> { + var value = evaluations.incrementAndGet(); + if (value == 3) + return null; // Simulate a value that disappears after 2 uses + + return "This is a value that can only be twice once, " + (2 - value) + " remaining"; + }, NON_NULL); + + processor.setTemplate(""" + {{#each $loop}} + {{$secret ?? "disappeared"}} + {{/each}} + """); + assertEquals(normalize(""" + This is a value that can only be twice once, 1 remaining + This is a value that can only be twice once, 0 remaining + disappeared + disappeared + """), processor.process()); + } } // ========== COMPARISON OPERATORS ========== From c07a8adb1ccb28428dccc35fa5d6d5739834d7d3 Mon Sep 17 00:00:00 2001 From: Farrael Date: Wed, 11 Feb 2026 01:15:24 +0100 Subject: [PATCH 11/30] feat: replace custom component children with slots --- .../au/ellie/hyui/html/TemplateProcessor.java | 43 +-- .../ellie/hyui/html/template/Evaluator.java | 97 +++++-- .../au/ellie/hyui/html/template/Lexer.java | 253 ++++++++++-------- .../au/ellie/hyui/html/template/Parser.java | 99 ++++--- .../template/context/ExecutionPolicy.java | 47 ++++ .../html/template/context/SlotSupplier.java | 51 ++++ .../html/template/context/VariableStack.java | 6 + .../ellie/hyui/html/template/item/Node.java | 18 +- .../hyui/html/template/item/Symbols.java | 18 +- .../ellie/hyui/html/template/item/Token.java | 9 +- .../hyui/html/TemplateProcessorTest.java | 70 ++++- 11 files changed, 475 insertions(+), 236 deletions(-) create mode 100644 src/main/java/au/ellie/hyui/html/template/context/ExecutionPolicy.java create mode 100644 src/main/java/au/ellie/hyui/html/template/context/SlotSupplier.java diff --git a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java index 3c856f1..f0978b1 100644 --- a/src/main/java/au/ellie/hyui/html/TemplateProcessor.java +++ b/src/main/java/au/ellie/hyui/html/TemplateProcessor.java @@ -23,6 +23,7 @@ import au.ellie.hyui.html.template.Evaluator; import au.ellie.hyui.html.template.Lexer; import au.ellie.hyui.html.template.Parser; +import au.ellie.hyui.html.template.context.ExecutionPolicy; import au.ellie.hyui.html.template.context.FilterRegistry; import au.ellie.hyui.html.template.context.VariableHandler.CachingVariableHandler; import au.ellie.hyui.html.template.context.VariableHandler.EphemeralVariableHandler; @@ -81,7 +82,7 @@ public class TemplateProcessor { private final Map variables = new HashMap<>(); private final Map components = new HashMap<>(); private final FilterRegistry filterRegistry = new FilterRegistry(); - private final CachedComponent root = new CachedComponent("__root__"); + private final CachedComponent root = new CachedComponent(); private ValueResolver valueResolver; private boolean preferDynamicValues; @@ -196,7 +197,7 @@ public TemplateProcessor registerComponent(@Nonnull String name, @Nonnull String assert !template.isEmpty() : "Component template cannot be empty."; - var cache = components.computeIfAbsent(name, _ -> new CachedComponent(name)); + var cache = components.computeIfAbsent(name, _ -> new CachedComponent()); var updated = cache.setTemplate(template); // Invalidate other components cache @@ -273,7 +274,7 @@ public String process(@Nullable Map additionalVariables) { if (additionalVariables != null) parameters.putAll(additionalVariables); - var rootAst = this.root.getAst(components); + var rootAst = this.root.getAst(); var scope = new VariableScope(ROOT_SCOPE_NAME, parameters); var stack = new VariableStack(scope, valueResolver, preferDynamicValues); @@ -343,11 +344,9 @@ public interface ValueResolver { public static class CachedComponent { private List ast; private String template; - private String name; - public CachedComponent(String name) { + public CachedComponent() { this.template = ""; - this.name = name; } /** @@ -393,41 +392,13 @@ public boolean invalidate() { * * @return The built template processor. */ - public List getAst(Map componentCache) { + public List getAst() { if (ast == null) { - List tokens = new Lexer(template, componentCache, name).tokenize(); + List tokens = new Lexer(template).tokenize(); ast = new Parser(tokens).parse(); } return ast; } } - - /** - * Execution policy for lazily evaluated variables. - */ - public enum ExecutionPolicy { - /** - * No caching. - * The value with be evaluated on every request. - */ - DYNAMIC, - - /** - * Cache the value after the first evaluation. - */ - CACHED, - - /** - * Evaluate the value only once, deleting it afterward. - * This is useful for one-time action. - */ - EPHEMERAL, - - /** - * Evaluate the value until the first null result, - * then delete it from the stack. - */ - NON_NULL - } } diff --git a/src/main/java/au/ellie/hyui/html/template/Evaluator.java b/src/main/java/au/ellie/hyui/html/template/Evaluator.java index 5286033..6117991 100644 --- a/src/main/java/au/ellie/hyui/html/template/Evaluator.java +++ b/src/main/java/au/ellie/hyui/html/template/Evaluator.java @@ -21,19 +21,20 @@ import au.ellie.hyui.HyUIPlugin; import au.ellie.hyui.html.TemplateProcessor.CachedComponent; import au.ellie.hyui.html.template.context.FilterRegistry; +import au.ellie.hyui.html.template.context.SlotSupplier; import au.ellie.hyui.html.template.context.VariableStack; import au.ellie.hyui.html.template.context.VariableStack.VariableScope; import au.ellie.hyui.html.template.exception.EvaluationException; -import au.ellie.hyui.html.template.exception.EvaluationException.ComponentNotFoundException; import au.ellie.hyui.html.template.item.Node; import au.ellie.hyui.html.template.item.Node.AttributeValueNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.DynamicAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.ExpressionAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.StaticAttributeNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ComponentBlockNode; import au.ellie.hyui.html.template.item.Node.BlockNode.EachBlockNode; import au.ellie.hyui.html.template.item.Node.BlockNode.IfBlockNode; -import au.ellie.hyui.html.template.item.Node.ComponentElementNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.SlotBlockNode; import au.ellie.hyui.html.template.item.Node.ExpressionNode; import au.ellie.hyui.html.template.item.Node.ExpressionNode.*; import au.ellie.hyui.html.template.item.Symbols; @@ -49,9 +50,11 @@ import static au.ellie.hyui.utils.ObjectUtils.toIterable; public class Evaluator { + private final static Stack STACK = new Stack<>(); + private final FilterRegistry filterRegistry; private final VariableStack contextStack; - private Map components; + private final Map components; public Evaluator(VariableStack context, FilterRegistry filterRegistry, Map components) { this.components = components; @@ -89,8 +92,9 @@ private String evaluateNode(Node node) { } case IfBlockNode ifBlock -> evaluateIfBlock(ifBlock); case EachBlockNode eachBlock -> evaluateEachBlock(eachBlock); - case ComponentElementNode component -> evaluateComponent(component); - case AttributeValueNode attributeValueNode -> evaluateAttributeValueNode(attributeValueNode); + case SlotBlockNode slotBlockNode -> evaluateSlotBlock(slotBlockNode); + case ComponentBlockNode component -> evaluateComponent(component); + case AttributeValueNode attributeValueNode -> evaluateAttributeString(attributeValueNode); default -> throw new IllegalStateException("Unexpected value: " + node); }; @@ -305,12 +309,11 @@ private String evaluateEachBlock(EachBlockNode node) { * @param component The `component` element node to evaluate. * @return The resulting string after evaluation. */ - private String evaluateComponent(ComponentElementNode component) { + private String evaluateComponent(ComponentBlockNode component) { var componentDef = component.tag(); - var cachedComponent = components.get(componentDef); - if (cachedComponent == null) - throw new ComponentNotFoundException("Component not found", component, componentDef); + if (!components.containsKey(componentDef) || STACK.contains(componentDef)) + return evaluateComponentString(component); // Attributes var context = new HashMap(); @@ -331,28 +334,84 @@ private String evaluateComponent(ComponentElementNode component) { // Children var scope = new VariableScope(COMPONENT_SCOPE_PREFIX + componentDef, context); - scope.putKeyed("children", (Supplier) () -> { - var result = new StringBuilder(); - for (Node child : component.children()) - result.append(evaluateNode(child)); - return result.toString(); - }); + for (var child : component.children()) { + var slotName = Symbols.HTML_SLOT_DEFAULT; + if (child instanceof SlotBlockNode slot) { + if (slot.name().startsWith(Symbols.HTML_SLOT_INPUT)) + slotName = slot.name().substring(Symbols.HTML_SLOT_INPUT.length()); + } + + // Saved as "slot.{slotName}" in component scope + scope.computeIfAbsent(Symbols.HTML_SLOT_KEY + slotName, key -> { + scope.getKeys().add(key); + return new SlotSupplier(this::evaluateNode); + }).add(child); + } contextStack.pushScope(scope); + STACK.push(componentDef); try { - return evaluate(cachedComponent.getAst(components)); + var cachedComponent = components.get(componentDef); + return evaluate(cachedComponent.getAst()); } finally { + STACK.pop(); contextStack.popScope(); } } + /** + * Evaluate a `slot` block node and return the resulting string. + * + * @param slotBlockNode The `slot` block node to evaluate. + */ + private String evaluateSlotBlock(SlotBlockNode slotBlockNode) { + var slotName = slotBlockNode.name(); + if (slotName.startsWith(Symbols.HTML_SLOT_OUTPUT)) + slotName = slotName.substring(Symbols.HTML_SLOT_OUTPUT.length() + 1); + + var content = contextStack.getVariable(Symbols.HTML_SLOT_KEY + slotName, () -> null); + if (content != null) + return content.toString(); + + return evaluate(slotBlockNode.children()); + } + + /** + * Evaluate a component as a string without processing it as a component. + * + * @param component The component element node to evaluate as a string. + * @return The resulting string representation of the component. + */ + private String evaluateComponentString(ComponentBlockNode component) { + var sb = new StringBuilder(); + sb.append("<").append(component.tag()); + + for (var attribute : component.attributes()) { + var attrStr = evaluateAttributeString(attribute); + if (!attrStr.isEmpty()) + sb.append(" ").append(attrStr); + } + + if (component.children().isEmpty()) + sb.append("/"); + sb.append(">"); + + for (Node child : component.children()) + sb.append(evaluateNode(child)); + + if (!component.children().isEmpty()) + sb.append(""); + + return sb.toString(); + } + /** * Evaluate an attribute value node and return the resulting string. * * @param attributeValueNode The attribute value node to evaluate. * @return The resulting string after evaluation. */ - private String evaluateAttributeValueNode(AttributeValueNode attributeValueNode) { + private String evaluateAttributeString(AttributeValueNode attributeValueNode) { var sb = new StringBuilder(); switch (attributeValueNode) { @@ -361,9 +420,7 @@ private String evaluateAttributeValueNode(AttributeValueNode attributeValueNode) case StaticAttributeNode staticNode -> sb.append(staticNode.getName()).append("=\"").append(staticNode.value()).append("\""); case FlagAttributeNode flag -> sb.append(flag.getName()); - default -> { - // We don't support expression attributes here - } + case ExpressionAttributeNode expression -> sb.append(evaluateNode(expression.expressions())); } return sb.toString(); diff --git a/src/main/java/au/ellie/hyui/html/template/Lexer.java b/src/main/java/au/ellie/hyui/html/template/Lexer.java index 8775e7f..8bc3b7e 100644 --- a/src/main/java/au/ellie/hyui/html/template/Lexer.java +++ b/src/main/java/au/ellie/hyui/html/template/Lexer.java @@ -18,29 +18,25 @@ package au.ellie.hyui.html.template; -import au.ellie.hyui.html.TemplateProcessor.CachedComponent; +import au.ellie.hyui.html.template.item.Symbols; import au.ellie.hyui.html.template.item.Token; import au.ellie.hyui.html.template.item.Token.Type; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Objects; import static au.ellie.hyui.html.template.item.Symbols.*; public class Lexer { - private final Map components; private final String input; - private final String name; + private int lineStart = 0; private int line = 1; private int pos = 0; - public Lexer(String input, Map components, String name) { - this.components = components; + public Lexer(String input) { this.input = input; - this.name = name; } /** @@ -50,16 +46,16 @@ public List tokenize() { var tokens = new ArrayList(); while (pos < input.length()) { - if (peek(EXPRESSION_START)) + if (peek(EXPRESSION_OPEN)) tokenizeExpressionOrBlock(tokens); - else if (isDeclaredComponent()) { - var savedPos = pos; - var executed = peek(COMPONENT_CLOSE) ? + else if (isHtml()) { + var startPos = pos; + var executed = peek(HTML_END) ? tokenizeEndComponent(tokens) : tokenizeStartComponent(tokens); if (!executed) { - pos = savedPos; + pos = startPos; tokenizeText(tokens); } } else @@ -77,35 +73,32 @@ else if (isDeclaredComponent()) { * @param tokens The list to add tokens to */ private void tokenizeExpressionOrBlock(List tokens) { - expect(EXPRESSION_START); - tokens.add(new Token(Type.EXPRESSION_OPEN, EXPRESSION_START, pos - EXPRESSION_START.length())); + var blockStartPos = pos; + var isBlock = true; + + expect(EXPRESSION_OPEN); + tokens.add(new Token(Type.EXPRESSION_OPEN, EXPRESSION_OPEN, pos - EXPRESSION_OPEN.length())); skipWhitespace(); - var consumeLine = false; if (consume(BLOCK_START)) { - consumeLine = true; - - trimWhitespaceForBlock(tokens); tokens.add(new Token(Type.BLOCK_HEAD, BLOCK_START, pos - BLOCK_START.length())); tokens.add(tokenizeIdentifier(Type.IDENTIFIER)); skipWhitespace(); } else if (consume(BLOCK_END)) { - consumeLine = true; - - trimWhitespaceForBlock(tokens); tokens.add(new Token(Type.BLOCK_TAIL, BLOCK_END, pos - BLOCK_END.length())); tokens.add(tokenizeIdentifier(Type.IDENTIFIER)); skipWhitespace(); - } + } else + isBlock = false; tokenizeExpression(tokens); - expect(EXPRESSION_END); - tokens.add(new Token(Type.EXPRESSION_CLOSE, EXPRESSION_END, pos - EXPRESSION_END.length())); + expect(EXPRESSION_CLOSE); + tokens.add(new Token(Type.EXPRESSION_CLOSE, EXPRESSION_CLOSE, pos - EXPRESSION_CLOSE.length())); - if (consumeLine) - skipBlockLineEnd(); + if (isBlock && isStandaloneLine(blockStartPos, pos)) + consumeStandaloneLine(tokens); } /** @@ -117,7 +110,7 @@ private void tokenizeExpression(List tokens) { skipWhitespace(); while (pos < input.length()) { - if (peek(EXPRESSION_END)) + if (peek(EXPRESSION_CLOSE)) break; var current = current(); @@ -191,7 +184,7 @@ private Token tokenizeVariable() { var current = current(); var builder = new StringBuilder(); - while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '_' || current == '-')) { + while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '_' || current == '-' || current == ':')) { builder.append(current); current = skip(); } @@ -276,9 +269,13 @@ private void tokenizeText(List tokens) { var start = pos; var builder = new StringBuilder(); - while (pos < input.length() && !peek(EXPRESSION_START) && !isDeclaredComponent()) { - builder.append(current()); + while (pos < input.length() && !peek(EXPRESSION_OPEN) && !isHtml()) { + var current = current(); + builder.append(current); skip(); + + if (current == '\n') + break; } if (!builder.isEmpty()) @@ -291,20 +288,15 @@ private void tokenizeText(List tokens) { * @param tokens The list to add tokens to */ private boolean tokenizeStartComponent(List tokens) { - expect(COMPONENT_START); - skipWhitespace(); + expect(HTML_OPEN); + tokens.add(new Token(Type.HTML_OPEN, HTML_OPEN, pos - HTML_OPEN.length())); - // Tag name - var identifier = tokenizeComponentName(); - if (Objects.equals(identifier.value(), this.name) || !components.containsKey(identifier.value())) - return false; - - tokens.add(new Token(Type.COMPONENT_OPEN, COMPONENT_START, identifier.position() - COMPONENT_START.length())); - tokens.add(new Token(Type.IDENTIFIER, identifier.value(), identifier.position())); + // Identifier + tokens.add(tokenizeComponentName()); skipWhitespace(); // Attributes - while (pos < input.length() && !peek(COMPONENT_END, COMPONENT_SELF_CLOSE)) { + while (pos < input.length() && !peek(HTML_CLOSE, HTML_END_SELF)) { // Attribute name if (Character.isLetter(current())) tokens.add(tokenizeComponentAttributeName()); @@ -316,15 +308,15 @@ private boolean tokenizeStartComponent(List tokens) { tokens.add(new Token(Type.ASSIGN, ASSIGN, pos - ASSIGN.length())); skipWhitespace(); - if (peek(EXPRESSION_START)) { + if (peek(EXPRESSION_OPEN)) tokenizeExpressionOrBlock(tokens); - } else if (peek(QUOTE)) + else if (peek(QUOTE)) tokens.add(tokenizeString()); else if (isNumberType()) tokens.add(tokenizeNumber()); else throwError("Unexpected character in attribute value: " + current(), pos); - } else if (peek(EXPRESSION_START)) + } else if (peek(EXPRESSION_OPEN)) tokenizeExpressionOrBlock(tokens); else throwError("Unexpected character in attribute value: " + current(), pos); @@ -333,12 +325,12 @@ else if (isNumberType()) } // Self-closing or normal close - var close = filter(COMPONENT_END, COMPONENT_SELF_CLOSE); + var close = filter(HTML_CLOSE, HTML_END_SELF); if (close != null) { - tokens.add(new Token(Type.COMPONENT_CLOSE, close, pos)); + tokens.add(new Token(Type.HTML_CLOSE, close, pos)); skip(close); } else - throwError("Expected '" + COMPONENT_END + "' or '" + COMPONENT_SELF_CLOSE + "' to close tag", pos); + throwError("Expected '" + HTML_CLOSE + "' or '" + HTML_END_SELF + "' to close tag", pos); return true; } @@ -347,22 +339,17 @@ else if (isNumberType()) * Tokenize an HTML end tag: */ private boolean tokenizeEndComponent(List tokens) { - expect(COMPONENT_CLOSE); - skipWhitespace(); + expect(HTML_END); + tokens.add(new Token(Type.HTML_OPEN, HTML_END, pos - HTML_OPEN.length())); - // Tag name - var identifier = tokenizeComponentName(); - if (Objects.equals(identifier.value(), this.name) || !components.containsKey(identifier.value())) - return false; - - tokens.add(new Token(Type.COMPONENT_OPEN, COMPONENT_CLOSE, identifier.position() - COMPONENT_START.length())); - tokens.add(new Token(Type.IDENTIFIER, identifier.value(), identifier.position())); + // Identifier + tokens.add(tokenizeComponentName()); skipWhitespace(); - if (consume(COMPONENT_END)) - tokens.add(new Token(Type.COMPONENT_CLOSE, COMPONENT_END, pos - COMPONENT_END.length())); + if (consume(HTML_CLOSE)) + tokens.add(new Token(Type.HTML_CLOSE, HTML_CLOSE, pos - HTML_CLOSE.length())); else - throwError("Expected '" + COMPONENT_END + "' to close end tag", pos); + throwError("Expected '" + HTML_CLOSE + "' to close end tag", pos); return true; } @@ -391,12 +378,16 @@ private Token tokenizeComponentName() { var current = current(); var builder = new StringBuilder(); - while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '-')) { + while (pos < input.length() && (Character.isLetterOrDigit(current) || current == '-' || current == ':')) { builder.append(current); current = skip(); } - return new Token(Type.IDENTIFIER, builder.toString(), start); + var identifier = builder.toString(); + var type = isSlotIdentifier(identifier) ? + Type.SLOT : Type.IDENTIFIER; + + return new Token(type, identifier, start); } // ===== Helpers ===== @@ -505,8 +496,10 @@ private char skip(String symbol) { */ private char skip(int count) { for (var i = 0; i < count && pos < input.length(); i++) { - if (input.charAt(pos) == '\n') + if (input.charAt(pos) == '\n') { + lineStart = pos + 1; line++; + } pos++; } @@ -524,24 +517,30 @@ private boolean isNumberType() { } /** - * Check if current position starts an HTML tag (not just a less-than operator) + * Check if the given identifier is a slot name + *
    +     * - Start with {@link Symbols#HTML_SLOT_OUTPUT} for output slots
    +     * - Start with {@link Symbols#HTML_SLOT_INPUT} for input slots
    +     * 
    + * + * @param id The identifier to check */ - private boolean isDeclaredComponent() { - if (!peek(COMPONENT_START)) - return false; - - var savedPos = this.pos; - var declaredComponent = false; + private boolean isSlotIdentifier(String id) { + return id.equals(HTML_SLOT_OUTPUT) + || id.startsWith(HTML_SLOT_OUTPUT + HTML_SLOT_INPUT) + || id.startsWith(HTML_SLOT_INPUT); + } - if (consume(COMPONENT_CLOSE) || consume(COMPONENT_START)) { - skipWhitespace(); - var identifier = tokenizeComponentName(); - if (!Objects.equals(identifier.value(), this.name) && components.containsKey(identifier.value())) - declaredComponent = true; - } + /** + * Check if current position starts an HTML tag + * and not just a less-than operator + */ + private boolean isHtml() { + if (!peek(HTML_OPEN)) + return false; - this.pos = savedPos; - return declaredComponent; + var next = next(); + return next == '/' || next == ':' || Character.isLetter(next); } // === Whitespace === @@ -550,63 +549,88 @@ private boolean isDeclaredComponent() { * Skip whitespace characters */ private void skipWhitespace() { - while (pos < input.length() && Character.isWhitespace(current())) - skip(); + var current = current(); + while (pos < input.length() && Character.isWhitespace(current)) + current = skip(); } /** - * Trim trailing whitespace from the last text token in a block - * if it only contains whitespace after the last newline + * Check if the block between blockStart and blockEnd is on a line by itself * - * @param tokens The list of tokens to trim + * @param blockStart The start position of the block (inclusive) + * @param blockEnd The end position of the block (exclusive) */ - private void trimWhitespaceForBlock(List tokens) { - if (tokens.size() < 2) - return; - - var last = tokens.get(tokens.size() - 2); - if (last.type() != Type.TEXT) - return; + private boolean isStandaloneLine(int blockStart, int blockEnd) { + // Check if there's only whitespace before the block on this line + for (var i = lineStart; i < blockStart; i++) { + var c = input.charAt(i); + if (c != ' ' && c != '\t') + return false; + } - var text = last.value(); - int lastNewlineIndex = text.lastIndexOf('\n'); + // Check if there's only whitespace after the block until newline + var i = blockEnd; + while (i < input.length()) { + var c = input.charAt(i); + if (c == '\n' || c == '\r') + return true; - if (lastNewlineIndex == -1) { - if (tokens.size() == 1 && text.matches("^[ \\t]+$")) - tokens.removeFirst(); + if (c != ' ' && c != '\t') + return false; - return; + i++; } - var afterLastNewline = text.substring(lastNewlineIndex + 1); - if (afterLastNewline.matches("^[ \\t]*$")) { - var keepPart = text.substring(0, lastNewlineIndex + 1); - tokens.set(tokens.size() - 2, new Token(Type.TEXT, keepPart, last.position())); - } + return true; } /** - * Skip whitespace and a newline if present after a standalone tag + * Consume the rest of the line after a standalone block */ - private void skipBlockLineEnd() { - var start = pos; + private void consumeStandaloneLine(List tokens) { + var index = tokens.size() - 1; + while (index >= 0) { + var token = tokens.get(index); + if (token.type() == Type.TEXT) { + if (!Objects.equals(token.value(), "\n") && token.value().isBlank()) + tokens.remove(index); - // Skip spaces and tabs - var current = current(); - while (pos < input.length() && (current == ' ' || current == '\t' || current == '\r')) - current = skip(); + break; + } - // Check for newline - if (pos < input.length() && current == '\n') - skip(); - else - pos = start; + index--; + } + + // Skip trailing spaces/tabs on the same line + while (pos < input.length()) { + var c = input.charAt(pos); + if (c == ' ' || c == '\t') + pos++; + else + break; + } + + // Skip the newline character(s) + if (pos < input.length()) { + var c = input.charAt(pos); + if (c == '\r') { + pos++; + if (pos < input.length() && input.charAt(pos) == '\n') + pos++; + lineStart = pos; + line++; + } else if (c == '\n') { + pos++; + lineStart = pos; + line++; + } + } } // === Errors === private String getLine(int lineNumber) { - var lines = input.split("\\R", -1); // handles \n, \r\n, etc. + var lines = input.split("\\R", -1); // handles \n,\t,\r\n, etc. if (lineNumber < 1 || lineNumber > lines.length) return ""; @@ -614,14 +638,15 @@ private String getLine(int lineNumber) { } private void throwError(String message, int errorPos) { - var arrow = " ".repeat(Math.max(0, errorPos)) + + int column = errorPos - lineStart; + var arrow = " ".repeat(Math.max(0, column)) + "↳ " + message; String formattedMessage = String.format(""" An error occurred when parsing the input at line %d, column %d %s %s - """, line, errorPos, getLine(line), arrow + """, line, column, getLine(line), arrow ); throw new RuntimeException(formattedMessage); diff --git a/src/main/java/au/ellie/hyui/html/template/Parser.java b/src/main/java/au/ellie/hyui/html/template/Parser.java index 5097367..e703a10 100644 --- a/src/main/java/au/ellie/hyui/html/template/Parser.java +++ b/src/main/java/au/ellie/hyui/html/template/Parser.java @@ -25,9 +25,10 @@ import au.ellie.hyui.html.template.item.Node.AttributeValueNode.ExpressionAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.FlagAttributeNode; import au.ellie.hyui.html.template.item.Node.AttributeValueNode.StaticAttributeNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.ComponentBlockNode; import au.ellie.hyui.html.template.item.Node.BlockNode.EachBlockNode; import au.ellie.hyui.html.template.item.Node.BlockNode.IfBlockNode; -import au.ellie.hyui.html.template.item.Node.ComponentElementNode; +import au.ellie.hyui.html.template.item.Node.BlockNode.SlotBlockNode; import au.ellie.hyui.html.template.item.Node.ExpressionNode; import au.ellie.hyui.html.template.item.Node.ExpressionNode.*; import au.ellie.hyui.html.template.item.Symbols; @@ -35,19 +36,15 @@ import au.ellie.hyui.html.template.item.Token.Type; import java.util.ArrayList; -import java.util.LinkedList; import java.util.List; -import java.util.Stack; import static au.ellie.hyui.html.template.item.Token.Type.*; public class Parser { - private final Stack context; private final List tokens; private int pos = 0; public Parser(List tokens) { - this.context = new Stack<>(); this.tokens = tokens; } @@ -61,7 +58,16 @@ public List parse() { while (!isAtEnd()) { var node = parseNode(); - if (node != null) + if (node == null) + continue; + + // Merge consecutive text nodes + if (!nodes.isEmpty() && + node instanceof TextNode(String content) && + nodes.getLast() instanceof TextNode(String prev) + ) + nodes.set(nodes.size() - 1, new TextNode(prev + content)); + else nodes.add(node); } @@ -77,11 +83,11 @@ private Node parseNode() { var token = current(); return switch (token.type()) { - case TEXT -> parseText(); case EXPRESSION_OPEN -> parseExpressionOrBlock(); - case COMPONENT_OPEN -> parseComponentElement(); + case HTML_OPEN -> parseHtmlElement(); case ATTRIBUTE -> parseAttribute(); - default -> throw new ParserException("Unexpected token", token, pos); + case EOF -> throw new ParserException("Unexpected end of input", token, pos); + default -> parseText(); }; } @@ -93,7 +99,7 @@ private Node parseNode() { * @return TextNode */ private TextNode parseText() { - return new TextNode(expect(TEXT).value()); + return new TextNode(expect(ANY).value()); } /** @@ -235,6 +241,7 @@ private Node parseExpressionOrBlock() { node = parseExpression(); expect(EXPRESSION_CLOSE); + return node; } @@ -323,51 +330,50 @@ private EachBlockNode parseEachBlock() { expect(EXPRESSION_OPEN); expect(BLOCK_TAIL); expect(IDENTIFIER, Symbols.SECTION_EACH); + return new EachBlockNode(itemName, collection, body); } // ===== Component ===== /** - * Parse an component element + * Parse an HTML element */ - private ComponentElementNode parseComponentElement() { - expect(COMPONENT_OPEN, Symbols.COMPONENT_START); - var identifier = expect(IDENTIFIER); - context.push(identifier); + private Node parseHtmlElement() { + expect(HTML_OPEN); + var identifier = exceptTagName(); // Parse attributes - var attributes = new LinkedList(); - - try { - while (!peek(COMPONENT_CLOSE) && !isAtEnd()) { - var attribute = parseAttribute(); - if (attribute instanceof AttributeValueNode attrNode) - attributes.add(attrNode); - else { - - } + var attributes = new ArrayList(); + while (!peek(HTML_CLOSE) && !isAtEnd()) { + var attribute = parseAttribute(); + if (attribute instanceof AttributeValueNode attrNode) + attributes.add(attrNode); + else { + // Loop / if / ... } - } finally { - context.pop(); } // Check for self-closing or regular close - var selfClosing = expect(COMPONENT_CLOSE).match(Symbols.COMPONENT_SELF_CLOSE); + var selfClosing = expect(HTML_CLOSE).match(Symbols.HTML_END_SELF); var children = selfClosing ? new ArrayList() : parseHtmlChildren(identifier.value()); - return new ComponentElementNode(identifier.value(), attributes, children); + // Normalize slot name + if (identifier.match(SLOT)) { + var slotName = identifier.value(); + if (slotName.equals(Symbols.HTML_SLOT_OUTPUT)) + slotName += ":" + Symbols.HTML_SLOT_DEFAULT; + + return new SlotBlockNode(slotName, attributes, children); + } + + return new ComponentBlockNode(identifier.value(), attributes, children); } /** * Parse an HTML attribute */ - private Node parseAttribute() { - var context = this.context.peek(); - if (context == null) - throw new ParserException("No HTML tag context for attribute", current(), pos); - var attribute = get(ATTRIBUTE); if (attribute != null) { var name = attribute.value(); @@ -390,7 +396,7 @@ private Node parseAttribute() { } else if (peek(EXPRESSION_OPEN)) return new ExpressionAttributeNode(parseExpressionOrBlock()); - throw new ParserException("Unexpected token in tag <" + context.value() + ">", current(), pos); + throw new ParserException("Unexpected non attribute token", current(), pos); } /** @@ -400,14 +406,14 @@ private List parseHtmlChildren(String parentTag) { var children = new ArrayList(); while (!isAtEnd()) { - var savedPos = pos; + var startPos = pos; // Detect closing tag - if (consume(COMPONENT_OPEN, Symbols.COMPONENT_CLOSE) && consume(IDENTIFIER, parentTag)) { - expect(COMPONENT_CLOSE); + if (consume(HTML_OPEN, Symbols.HTML_END) && (consume(IDENTIFIER, parentTag) || consume(SLOT))) { + expect(HTML_CLOSE); return children; } else - pos = savedPos; + pos = startPos; // Parse children Node child = parseNode(); @@ -509,6 +515,21 @@ private Token expect(Type type, String... values) { throw new ParserException("Expected " + type + (values.length > 0 ? " with value \"" + String.join("/", values) + "\"" : ""), token, pos); } + /** + * Except the token to match either a SLOT or IDENTIFIER with the given value, consuming it. + * + * @throws ParserException if the expected type or value do not match + */ + private Token exceptTagName() { + var token = current(); + + if (!token.match(SLOT) && !token.match(IDENTIFIER)) + throw new ParserException("Expected identifier", token, pos); + + skip(); + return token; + } + /** * Check if we have reached the end of the token list */ diff --git a/src/main/java/au/ellie/hyui/html/template/context/ExecutionPolicy.java b/src/main/java/au/ellie/hyui/html/template/context/ExecutionPolicy.java new file mode 100644 index 0000000..67b67f2 --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/ExecutionPolicy.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +/** + * Execution policy for lazily evaluated variables. + */ +public enum ExecutionPolicy { + /** + * No caching. + * The value with be evaluated on every request. + */ + DYNAMIC, + + /** + * Cache the value after the first evaluation. + */ + CACHED, + + /** + * Evaluate the value only once, deleting it afterward. + * This is useful for one-time action. + */ + EPHEMERAL, + + /** + * Evaluate the value until the first null result, + * then delete it from the stack. + */ + NON_NULL +} \ No newline at end of file diff --git a/src/main/java/au/ellie/hyui/html/template/context/SlotSupplier.java b/src/main/java/au/ellie/hyui/html/template/context/SlotSupplier.java new file mode 100644 index 0000000..362ff7e --- /dev/null +++ b/src/main/java/au/ellie/hyui/html/template/context/SlotSupplier.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 EllieAU + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + * + */ + +package au.ellie.hyui.html.template.context; + +import au.ellie.hyui.html.template.item.Node; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +public class SlotSupplier implements Supplier { + private final Function handler; + private List nodes; + + public SlotSupplier(Function handler) { + this.handler = handler; + } + + public void add(Node node) { + if (nodes == null) + nodes = new ArrayList<>(); + + nodes.add(node); + } + + @Override + public String get() { + var result = new StringBuilder(); + for (Node node : nodes) + result.append(handler.apply(node)); + + return result.toString().trim(); + } +} diff --git a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java index 3745029..4f04af2 100644 --- a/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java +++ b/src/main/java/au/ellie/hyui/html/template/context/VariableStack.java @@ -24,6 +24,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.*; +import java.util.function.Function; import java.util.function.Supplier; public class VariableStack { @@ -203,6 +204,11 @@ public Object get(String key) { return content.get(key); } + @SuppressWarnings("unchecked") + public T computeIfAbsent(String key, Function defaultValue) { + return (T) content.computeIfAbsent(key, defaultValue); + } + public void put(String key, Object value) { content.put(key, value); } diff --git a/src/main/java/au/ellie/hyui/html/template/item/Node.java b/src/main/java/au/ellie/hyui/html/template/item/Node.java index ced1794..e6adabf 100644 --- a/src/main/java/au/ellie/hyui/html/template/item/Node.java +++ b/src/main/java/au/ellie/hyui/html/template/item/Node.java @@ -82,6 +82,17 @@ record IfBlockNode(ExpressionNode condition, List thenBody, List els */ record EachBlockNode(String itemName, ExpressionNode collection, List body) implements BlockNode { } + + /** + * Represents an HTML element with attributes and children + */ + record ComponentBlockNode(String tag, List attributes, + List children) implements BlockNode { + } + + record SlotBlockNode(String name, List attributes, + List children) implements BlockNode { + } } // ---- Component Nodes ---- @@ -113,12 +124,5 @@ public String getName() { } } } - - record ComponentElementNode( - String tag, - List attributes, - List children - ) implements Node { - } } diff --git a/src/main/java/au/ellie/hyui/html/template/item/Symbols.java b/src/main/java/au/ellie/hyui/html/template/item/Symbols.java index 9b603d2..8e5d648 100644 --- a/src/main/java/au/ellie/hyui/html/template/item/Symbols.java +++ b/src/main/java/au/ellie/hyui/html/template/item/Symbols.java @@ -24,15 +24,21 @@ public class Symbols { public final static String QUOTE = "\""; public final static String ASSIGN = "="; - public final static String EXPRESSION_START = "{{"; - public final static String EXPRESSION_END = "}}"; + public final static String EXPRESSION_OPEN = "{{"; + public final static String EXPRESSION_CLOSE = "}}"; public final static String BLOCK_START = "#"; public final static String BLOCK_END = "/"; - public final static String COMPONENT_START = "<"; - public final static String COMPONENT_END = ">"; - public final static String COMPONENT_SELF_CLOSE = "/>"; - public final static String COMPONENT_CLOSE = ""; + public final static String HTML_END = ""; + + public final static String HTML_SLOT_INPUT = ":"; + public final static String HTML_SLOT_OUTPUT = "slot"; + + public static final String HTML_SLOT_DEFAULT = "default"; + public static final String HTML_SLOT_KEY = "slot:"; public final static String PIPE = "|"; public final static String EQUALS = "=="; diff --git a/src/main/java/au/ellie/hyui/html/template/item/Token.java b/src/main/java/au/ellie/hyui/html/template/item/Token.java index 930bee9..659f4ae 100644 --- a/src/main/java/au/ellie/hyui/html/template/item/Token.java +++ b/src/main/java/au/ellie/hyui/html/template/item/Token.java @@ -19,6 +19,7 @@ package au.ellie.hyui.html.template.item; public record Token(Type type, String value, int position) { + /** * Check if the token matches the given type and value * @@ -26,7 +27,7 @@ public record Token(Type type, String value, int position) { * @param symbols The values to check */ public boolean match(Type type, String... symbols) { - if (this.type != type) + if (this.type != type && type != Type.ANY) return false; if (symbols.length == 0) @@ -65,20 +66,22 @@ public enum Type { COMPARATOR, OPERATOR, ASSIGN, + SLOT, // Expression EXPRESSION_OPEN, EXPRESSION_CLOSE, // Components - COMPONENT_OPEN, - COMPONENT_CLOSE, + HTML_OPEN, + HTML_CLOSE, // Block BLOCK_HEAD, BLOCK_TAIL, // Special + ANY, EOF } } diff --git a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java index 2d32c99..e30db91 100644 --- a/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java +++ b/src/test/java/au/ellie/hyui/html/TemplateProcessorTest.java @@ -13,7 +13,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import static au.ellie.hyui.html.TemplateProcessor.ExecutionPolicy.*; +import static au.ellie.hyui.html.template.context.ExecutionPolicy.*; import static au.ellie.hyui.html.template.context.VariableStack.VariableScope.EACH_SCOPE_NAME; import static org.junit.jupiter.api.Assertions.*; @@ -271,7 +271,7 @@ void policyEphemeralEvaluation(boolean condition, String expected) { {{#if $condition && $secret}}{{/if}} {{$secret ?? "secret already revealed: disappeared"}} """); - assertEquals(expected, processor.process()); + assertEquals(expected, processor.process().trim()); } @Test @@ -864,7 +864,7 @@ void callFunctionWithArgumentsAndDynamic() { processor.setVariable("style", (stack) -> (int) stack.getVariable("key") < 3 ? "color: red;" : null, DYNAMIC); processor.registerComponent("module", """ - Module {{$key}} -> Active : {{ $active ?? false }} + Module {{$key}} -> Active : {{ $active ?? false }} """); processor.setTemplate(normalize(""" @@ -921,8 +921,7 @@ void complexRealWorldTemplate() { new Item("preset_02", true, "Test name 02", 1) )); - - processor.setTemplate(normalize(""" + processor.setTemplate("""
    @@ -930,15 +929,15 @@ void complexRealWorldTemplate() { {{#if $render && $preset-list.size > 1}}
    - {{#each $preset-list}} - + {{/each}}
    {{/if}}
    - """)); + """); assertEquals(normalize("""
    @@ -1035,7 +1034,7 @@ void componentPrioritizeVariableFromLocalScope() { @Test @DisplayName("Should allow components to pass existing parameters") void componentCanPassExistingParameters() { - processor.registerComponent("button", ""); + processor.registerComponent("button", ""); processor.setTemplate("