From d2073e68f22ed5a9f61d5880f6220686dbab9cbb Mon Sep 17 00:00:00 2001 From: Tristan Tarrant Date: Mon, 23 Mar 2026 11:30:50 +0100 Subject: [PATCH 1/3] [#372] Fix aesh-processor for inner classes and custom CommandInvocation types Two bugs in the annotation processor: 1. Inner class commands (e.g., Config.Set) used qualifiedName string splitting to derive the package, producing invalid package names like "org.example.Config". Now uses elementUtils.getPackageOf() and flattens inner class names with underscore (Config_Set_AeshMetadata). 2. Generated code used typed ProcessedCommandBuilder.builder() which fails when commands use custom CommandInvocation subtypes. Now casts to raw ProcessedCommandBuilder since @SuppressWarnings is already present. --- .../processor/AeshAnnotationProcessor.java | 22 +++++++++++++------ .../org/aesh/processor/CodeGenerator.java | 3 ++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java b/aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java index 62369179..e39f1c0b 100644 --- a/aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java +++ b/aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java @@ -211,13 +211,21 @@ private void collectFieldsRecursive(TypeElement element, List f private void generateProvider(TypeElement commandElement) throws IOException { String qualifiedName = commandElement.getQualifiedName().toString(); - String packageName = ""; - int lastDot = qualifiedName.lastIndexOf('.'); - if (lastDot > 0) { - packageName = qualifiedName.substring(0, lastDot); + String packageName = elementUtils.getPackageOf(commandElement).getQualifiedName().toString(); + + // For inner classes (e.g., Config.Set), we need: + // - typeRefName: "Config.Set" for use in generated code type references + // - metadataClassName: "Config_Set_AeshMetadata" to avoid clashing with enclosing class + String typeRefName; + String metadataClassName; + if (packageName.isEmpty()) { + typeRefName = qualifiedName; + metadataClassName = qualifiedName.replace('.', '_') + "_AeshMetadata"; + } else { + // Strip package prefix to get e.g. "Config.Set" for inner classes or "Batch" for top-level + typeRefName = qualifiedName.substring(packageName.length() + 1); + metadataClassName = typeRefName.replace('.', '_') + "_AeshMetadata"; } - String simpleName = commandElement.getSimpleName().toString(); - String metadataClassName = simpleName + "_AeshMetadata"; String fullMetadataName = packageName.isEmpty() ? metadataClassName : packageName + "." + metadataClassName; boolean isGroup = commandElement.getAnnotation(GroupCommandDefinition.class) != null; @@ -225,7 +233,7 @@ private void generateProvider(TypeElement commandElement) throws IOException { List fields = collectFields(commandElement); String code = CodeGenerator.generate( - packageName, simpleName, metadataClassName, qualifiedName, + packageName, typeRefName, metadataClassName, qualifiedName, commandElement, fields, isGroup, elementUtils, typeUtils); JavaFileObject sourceFile = filer.createSourceFile(fullMetadataName, commandElement); diff --git a/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java b/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java index 4bd07076..4979fb15 100644 --- a/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java +++ b/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java @@ -176,7 +176,8 @@ private static void generateBuildProcessedCommand(StringBuilder sb, TypeElement List fields, boolean isGroup, Elements elementUtils, Types typeUtils) { - sb.append(" ProcessedCommand processedCommand = ProcessedCommandBuilder.builder()\n"); + sb.append( + " ProcessedCommand processedCommand = ((ProcessedCommandBuilder) ProcessedCommandBuilder.builder())\n"); if (isGroup) { GroupCommandDefinition gcd = commandElement.getAnnotation(GroupCommandDefinition.class); From ea2d5dfdccb547695bc5606a7d1381b8e736e574 Mon Sep 17 00:00:00 2001 From: Tristan Tarrant Date: Mon, 23 Mar 2026 13:17:26 +0100 Subject: [PATCH 2/3] [#372] Add reflection-free field population support for GraalVM native image Add BiConsumer fieldSetter and Consumer fieldResetter to ProcessedOption, allowing the annotation processor to generate direct field access lambdas instead of relying on reflection. This enables aesh commands to work in GraalVM native images without reflection metadata for field population. - ProcessedOption: add fieldSetter/fieldResetter, injectValueWithSetter(), resetField() methods - ProcessedOptionBuilder: add fieldSetter/fieldResetter builder methods - AeshCommandPopulator: delegate resetField to ProcessedOption - CodeGenerator: generate fieldSetter/fieldResetter lambdas for non-private fields, skip private fields (fall back to reflection) --- .../org/aesh/processor/CodeGenerator.java | 100 ++++++++++++++--- .../impl/internal/ProcessedOption.java | 101 ++++++++++++++++++ .../impl/internal/ProcessedOptionBuilder.java | 18 +++- .../impl/populator/AeshCommandPopulator.java | 6 +- 4 files changed, 206 insertions(+), 19 deletions(-) diff --git a/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java b/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java index 4979fb15..39683295 100644 --- a/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java +++ b/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java @@ -73,7 +73,9 @@ static String generate( } // Imports - sb.append("import java.util.Arrays;\n\n"); + sb.append("import java.util.Arrays;\n"); + sb.append("import java.util.function.BiConsumer;\n"); + sb.append("import java.util.function.Consumer;\n\n"); sb.append("import org.aesh.command.Command;\n"); sb.append("import org.aesh.command.impl.internal.ProcessedCommand;\n"); sb.append("import org.aesh.command.impl.internal.ProcessedCommandBuilder;\n"); @@ -123,7 +125,7 @@ static String generate( sb.append(" @Override\n"); sb.append(" public ProcessedCommand buildProcessedCommand(").append(simpleName).append(" instance)"); sb.append(" throws CommandLineParserException {\n"); - generateBuildProcessedCommand(sb, commandElement, fields, isGroup, elementUtils, typeUtils); + generateBuildProcessedCommand(sb, simpleName, commandElement, fields, isGroup, elementUtils, typeUtils); sb.append(" }\n"); // End class @@ -172,7 +174,7 @@ private static List getGroupCommandClassNames(TypeElement element, Eleme return classNames; } - private static void generateBuildProcessedCommand(StringBuilder sb, TypeElement commandElement, + private static void generateBuildProcessedCommand(StringBuilder sb, String simpleName, TypeElement commandElement, List fields, boolean isGroup, Elements elementUtils, Types typeUtils) { @@ -210,7 +212,7 @@ private static void generateBuildProcessedCommand(StringBuilder sb, TypeElement // Process fields for (VariableElement field : fields) { - generateFieldProcessing(sb, field, elementUtils, typeUtils); + generateFieldProcessing(sb, simpleName, field, elementUtils, typeUtils); } sb.append(" return processedCommand;\n"); @@ -246,7 +248,7 @@ private static void generateCommandActivator(StringBuilder sb, TypeElement eleme } } - private static void generateFieldProcessing(StringBuilder sb, VariableElement field, + private static void generateFieldProcessing(StringBuilder sb, String simpleName, VariableElement field, Elements elementUtils, Types typeUtils) { Option o = field.getAnnotation(Option.class); OptionList ol = field.getAnnotation(OptionList.class); @@ -255,19 +257,19 @@ private static void generateFieldProcessing(StringBuilder sb, VariableElement fi Argument arg = field.getAnnotation(Argument.class); if (o != null) { - generateOption(sb, field, o, elementUtils, typeUtils); + generateOption(sb, simpleName, field, o, elementUtils, typeUtils); } else if (ol != null) { - generateOptionList(sb, field, ol, elementUtils, typeUtils); + generateOptionList(sb, simpleName, field, ol, elementUtils, typeUtils); } else if (og != null) { - generateOptionGroup(sb, field, og, elementUtils, typeUtils); + generateOptionGroup(sb, simpleName, field, og, elementUtils, typeUtils); } else if (args != null) { - generateArguments(sb, field, args, elementUtils, typeUtils); + generateArguments(sb, simpleName, field, args, elementUtils, typeUtils); } else if (arg != null) { - generateArgument(sb, field, arg, elementUtils, typeUtils); + generateArgument(sb, simpleName, field, arg, elementUtils, typeUtils); } } - private static void generateOption(StringBuilder sb, VariableElement field, Option o, + private static void generateOption(StringBuilder sb, String simpleName, VariableElement field, Option o, Elements elementUtils, Types typeUtils) { String fieldName = field.getSimpleName().toString(); String fieldType = getBoxedTypeName(field.asType(), typeUtils); @@ -309,10 +311,12 @@ private static void generateOption(StringBuilder sb, VariableElement field, Opti sb.append(" .inherited(").append(o.inherited()).append(")\n"); sb.append(" .descriptionUrl(").append(stringLiteral(o.descriptionUrl())).append(")\n"); sb.append(" .url(").append(o.url()).append(")\n"); + generateFieldSetter(sb, simpleName, field, typeUtils); + generateFieldResetter(sb, simpleName, field, typeUtils); sb.append(" .build());\n\n"); } - private static void generateOptionList(StringBuilder sb, VariableElement field, OptionList ol, + private static void generateOptionList(StringBuilder sb, String simpleName, VariableElement field, OptionList ol, Elements elementUtils, Types typeUtils) { String fieldName = field.getSimpleName().toString(); String elementType = getGenericTypeArgument(field.asType(), 0, typeUtils); @@ -338,10 +342,12 @@ private static void generateOptionList(StringBuilder sb, VariableElement field, generateOptionActivator(sb, field, "activator", elementUtils); generateOptionRenderer(sb, field, "renderer", elementUtils); generateOptionParser(sb, field, "parser", elementUtils); + generateFieldSetter(sb, simpleName, field, typeUtils); + generateFieldResetter(sb, simpleName, field, typeUtils); sb.append(" .build());\n\n"); } - private static void generateOptionGroup(StringBuilder sb, VariableElement field, OptionGroup og, + private static void generateOptionGroup(StringBuilder sb, String simpleName, VariableElement field, OptionGroup og, Elements elementUtils, Types typeUtils) { String fieldName = field.getSimpleName().toString(); // For Map, extract V (index 1) @@ -367,10 +373,12 @@ private static void generateOptionGroup(StringBuilder sb, VariableElement field, generateOptionActivator(sb, field, "activator", elementUtils); generateOptionRenderer(sb, field, "renderer", elementUtils); generateOptionParser(sb, field, "parser", elementUtils); + generateFieldSetter(sb, simpleName, field, typeUtils); + generateFieldResetter(sb, simpleName, field, typeUtils); sb.append(" .build());\n\n"); } - private static void generateArguments(StringBuilder sb, VariableElement field, Arguments a, + private static void generateArguments(StringBuilder sb, String simpleName, VariableElement field, Arguments a, Elements elementUtils, Types typeUtils) { String fieldName = field.getSimpleName().toString(); String elementType = getGenericTypeArgument(field.asType(), 0, typeUtils); @@ -395,10 +403,12 @@ private static void generateArguments(StringBuilder sb, VariableElement field, A generateOptionActivator(sb, field, "activator", elementUtils); generateOptionParser(sb, field, "parser", elementUtils); sb.append(" .url(").append(a.url()).append(")\n"); + generateFieldSetter(sb, simpleName, field, typeUtils); + generateFieldResetter(sb, simpleName, field, typeUtils); sb.append(" .build());\n\n"); } - private static void generateArgument(StringBuilder sb, VariableElement field, Argument arg, + private static void generateArgument(StringBuilder sb, String simpleName, VariableElement field, Argument arg, Elements elementUtils, Types typeUtils) { String fieldName = field.getSimpleName().toString(); String fieldType = getBoxedTypeName(field.asType(), typeUtils); @@ -426,6 +436,8 @@ private static void generateArgument(StringBuilder sb, VariableElement field, Ar sb.append(" .overrideRequired(").append(arg.overrideRequired()).append(")\n"); sb.append(" .inherited(").append(arg.inherited()).append(")\n"); sb.append(" .url(").append(arg.url()).append(")\n"); + generateFieldSetter(sb, simpleName, field, typeUtils); + generateFieldResetter(sb, simpleName, field, typeUtils); sb.append(" .build());\n\n"); } @@ -653,6 +665,64 @@ private static String selectorLiteral(org.aesh.selector.SelectorType selectorTyp return "org.aesh.selector.SelectorType." + selectorType.name(); } + private static boolean isAccessibleField(VariableElement field) { + java.util.Set modifiers = field.getModifiers(); + return !modifiers.contains(javax.lang.model.element.Modifier.PRIVATE); + } + + private static void generateFieldSetter(StringBuilder sb, String commandSimpleName, VariableElement field, + Types typeUtils) { + if (!isAccessibleField(field)) + return; + String fieldName = field.getSimpleName().toString(); + String fieldType = field.asType().toString(); + + sb.append(" .fieldSetter((inst, val) -> ((").append(commandSimpleName).append(") inst).") + .append(fieldName).append(" = (").append(fieldType).append(") val)\n"); + } + + private static void generateFieldResetter(StringBuilder sb, String commandSimpleName, VariableElement field, + Types typeUtils) { + if (!isAccessibleField(field)) + return; + String fieldName = field.getSimpleName().toString(); + TypeMirror fieldType = field.asType(); + + sb.append(" .fieldResetter(inst -> ((").append(commandSimpleName).append(") inst).") + .append(fieldName).append(" = "); + + switch (fieldType.getKind()) { + case BOOLEAN: + sb.append("false"); + break; + case BYTE: + sb.append("(byte) 0"); + break; + case SHORT: + sb.append("(short) 0"); + break; + case INT: + sb.append("0"); + break; + case LONG: + sb.append("0L"); + break; + case FLOAT: + sb.append("0.0f"); + break; + case DOUBLE: + sb.append("0.0d"); + break; + case CHAR: + sb.append("'\\u0000'"); + break; + default: + sb.append("null"); + break; + } + sb.append(")\n"); + } + private static String escapeJavaString(String s) { StringBuilder sb = new StringBuilder(); for (char c : s.toCharArray()) { diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java index f042cb8f..34426f70 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java @@ -31,6 +31,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.aesh.command.activator.OptionActivator; import org.aesh.command.completer.OptionCompleter; @@ -92,6 +94,8 @@ public final class ProcessedOption { private boolean inherited = false; private String descriptionUrl; private boolean isUrl = false; + private BiConsumer fieldSetter; + private Consumer fieldResetter; public ProcessedOption(char shortName, String name, String description, String argument, boolean required, char valueSeparator, boolean askIfNotSet, boolean acceptNameWithoutDashes, @@ -159,6 +163,60 @@ public ProcessedOption(char shortName, String name, String description, values = new ArrayList<>(); } + public void setFieldSetter(BiConsumer fieldSetter) { + this.fieldSetter = fieldSetter; + } + + public void setFieldResetter(Consumer fieldResetter) { + this.fieldResetter = fieldResetter; + } + + public BiConsumer getFieldSetter() { + return fieldSetter; + } + + public Consumer getFieldResetter() { + return fieldResetter; + } + + public void resetField(Object instance) { + if (fieldResetter != null) { + fieldResetter.accept(instance); + return; + } + // Fallback to reflection + try { + Field field = getField(instance.getClass(), fieldName); + if (field == null) + return; + if (!Modifier.isPublic(field.getModifiers())) + field.setAccessible(true); + if (field.getType().isPrimitive()) { + if (boolean.class.isAssignableFrom(field.getType())) + field.set(instance, false); + else if (int.class.isAssignableFrom(field.getType())) + field.set(instance, 0); + else if (short.class.isAssignableFrom(field.getType())) + field.set(instance, (short) 0); + else if (char.class.isAssignableFrom(field.getType())) + field.set(instance, '\u0000'); + else if (byte.class.isAssignableFrom(field.getType())) + field.set(instance, (byte) 0); + else if (long.class.isAssignableFrom(field.getType())) + field.set(instance, 0L); + else if (float.class.isAssignableFrom(field.getType())) + field.set(instance, 0.0f); + else if (double.class.isAssignableFrom(field.getType())) + field.set(instance, 0.0d); + } else if (!hasValue() && field.getType().equals(Boolean.class)) { + field.set(instance, Boolean.FALSE); + } else + field.set(instance, null); + } catch (NoSuchFieldException | IllegalAccessException e) { + // Field reset failed, continue + } + } + public String shortName() { return shortName; } @@ -516,6 +574,49 @@ public void injectValueIntoField(Object instance, InvocationProviders invocation boolean doValidation) throws OptionValidatorException { if (converter == null || instance == null) return; + if (fieldSetter != null) { + injectValueWithSetter(instance, invocationProviders, aeshContext, doValidation); + } else { + injectValueWithReflection(instance, invocationProviders, aeshContext, doValidation); + } + } + + private void injectValueWithSetter(Object instance, InvocationProviders invocationProviders, AeshContext aeshContext, + boolean doValidation) throws OptionValidatorException { + if (optionType == OptionType.NORMAL || optionType == OptionType.BOOLEAN || optionType == OptionType.ARGUMENT) { + if (negatedByUser && optionType == OptionType.BOOLEAN) { + fieldSetter.accept(instance, doConvert("false", invocationProviders, instance, aeshContext, doValidation)); + } else if (getValue() != null) + fieldSetter.accept(instance, doConvert(getValue(), invocationProviders, instance, aeshContext, doValidation)); + else if (defaultValues.size() > 0) { + fieldSetter.accept(instance, + doConvert(defaultValues.get(0), invocationProviders, instance, aeshContext, doValidation)); + } + } else if (optionType == OptionType.LIST || optionType == OptionType.ARGUMENTS) { + Collection tmpSet; + if (Set.class.isAssignableFrom(type)) + tmpSet = new HashSet<>(); + else + tmpSet = new ArrayList<>(); + if (values.size() > 0) { + for (String in : values) + tmpSet.add(doConvert(in, invocationProviders, instance, aeshContext, doValidation)); + } else if (defaultValues.size() > 0) { + for (String in : defaultValues) + tmpSet.add(doConvert(in, invocationProviders, instance, aeshContext, doValidation)); + } + fieldSetter.accept(instance, tmpSet); + } else if (optionType == OptionType.GROUP) { + Map tmpMap = newHashMap(); + for (String propertyKey : properties.keySet()) + tmpMap.put(propertyKey, doConvert(properties.get(propertyKey), invocationProviders, instance, + aeshContext, doValidation)); + fieldSetter.accept(instance, tmpMap); + } + } + + private void injectValueWithReflection(Object instance, InvocationProviders invocationProviders, AeshContext aeshContext, + boolean doValidation) throws OptionValidatorException { try { Field field = getField(instance.getClass(), fieldName); //for some options, the field might be null. eg generatedHelp diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java index 5770b32f..25a8b864 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Consumer; import org.aesh.command.activator.OptionActivator; @@ -80,6 +81,8 @@ public class ProcessedOptionBuilder { private boolean inherited = false; private String descriptionUrl; private boolean isUrl = false; + private BiConsumer fieldSetter; + private Consumer fieldResetter; private ProcessedOptionBuilder() { defaultValues = new ArrayList<>(); @@ -339,6 +342,14 @@ public ProcessedOptionBuilder url(boolean isUrl) { return apply(c -> c.isUrl = isUrl); } + public ProcessedOptionBuilder fieldSetter(BiConsumer fieldSetter) { + return apply(c -> c.fieldSetter = fieldSetter); + } + + public ProcessedOptionBuilder fieldResetter(Consumer fieldResetter) { + return apply(c -> c.fieldResetter = fieldResetter); + } + public ProcessedOption build() throws OptionParserException { if (optionType == null) { if (!hasValue) @@ -390,10 +401,15 @@ else if (hasMultipleValues) throw new OptionParserException("Option '" + name + "' is marked as negatable but is not a boolean type"); } - return new ProcessedOption(shortName, name, description, argument, required, + ProcessedOption option = new ProcessedOption(shortName, name, description, argument, required, valueSeparator, askIfNotSet, acceptNameWithoutDashes, selectorType, defaultValues, type, fieldName, optionType, converter, completer, validator, activator, renderer, parser, overrideRequired, negatable, negationPrefix, inherited, descriptionUrl, isUrl); + if (fieldSetter != null) + option.setFieldSetter(fieldSetter); + if (fieldResetter != null) + option.setFieldResetter(fieldResetter); + return option; } } diff --git a/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java b/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java index 45d64f07..ab9b04dd 100644 --- a/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java +++ b/aesh/src/main/java/org/aesh/command/impl/populator/AeshCommandPopulator.java @@ -75,7 +75,7 @@ else if (option.getDefaultValues().size() > 0 && option.selectorType() == Select option.injectValueIntoField(getObject(), invocationProviders, aeshContext, mode == CommandLineParser.Mode.VALIDATE); else - resetField(getObject(), option.getFieldName(), option.hasValue()); + option.resetField(getObject()); } //arguments if (processedCommand.getArguments() != null && @@ -84,7 +84,7 @@ else if (option.getDefaultValues().size() > 0 && option.selectorType() == Select processedCommand.getArguments().injectValueIntoField(getObject(), invocationProviders, aeshContext, mode == CommandLineParser.Mode.VALIDATE); else if (processedCommand.getArguments() != null) - resetField(getObject(), processedCommand.getArguments().getFieldName(), true); + processedCommand.getArguments().resetField(getObject()); //argument if (processedCommand.getArgument() != null && (processedCommand.getArgument().getValues().size() > 0 || @@ -92,7 +92,7 @@ else if (processedCommand.getArguments() != null) processedCommand.getArgument().injectValueIntoField(getObject(), invocationProviders, aeshContext, mode == CommandLineParser.Mode.VALIDATE); else if (processedCommand.getArgument() != null) - resetField(getObject(), processedCommand.getArgument().getFieldName(), true); + processedCommand.getArgument().resetField(getObject()); } @Override From dd5954832646e4fd7b747f8c974542d1e7ad347e Mon Sep 17 00:00:00 2001 From: Tristan Tarrant Date: Mon, 23 Mar 2026 13:55:09 +0100 Subject: [PATCH 3/3] [#372] Fix field setter/resetter lost in ProcessedCommand.addOption() copy ProcessedCommand.addOption() and setOptions() create a copy of the ProcessedOption but didn't carry over fieldSetter/fieldResetter, causing the annotation processor's generated field accessors to be silently discarded. Also adds comprehensive tests for field setter population. --- .../impl/internal/ProcessedCommand.java | 18 +- .../populator/FieldSetterPopulatorTest.java | 241 ++++++++++++++++++ .../MetadataProviderFieldSetterTest.java | 183 +++++++++++++ 3 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 aesh/src/test/java/org/aesh/command/populator/FieldSetterPopulatorTest.java create mode 100644 aesh/src/test/java/org/aesh/command/populator/MetadataProviderFieldSetterTest.java diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java index 62fff729..156016b3 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java @@ -135,26 +135,36 @@ public List getAliases() { } public void addOption(ProcessedOption opt) throws OptionParserException { - this.options.add(new ProcessedOption(verifyThatNamesAreUnique(opt.shortName(), opt.name()), opt.name(), + ProcessedOption copy = new ProcessedOption(verifyThatNamesAreUnique(opt.shortName(), opt.name()), opt.name(), opt.description(), opt.getArgument(), opt.isRequired(), opt.getValueSeparator(), opt.askIfNotSet(), opt.acceptNameWithoutDashes(), opt.selectorType(), opt.getDefaultValues(), opt.type(), opt.getFieldName(), opt.getOptionType(), opt.converter(), opt.completer(), opt.validator(), opt.activator(), opt.getRenderer(), opt.parser(), opt.doOverrideRequired(), opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited(), - opt.getDescriptionUrl(), opt.isUrl())); + opt.getDescriptionUrl(), opt.isUrl()); + if (opt.getFieldSetter() != null) + copy.setFieldSetter(opt.getFieldSetter()); + if (opt.getFieldResetter() != null) + copy.setFieldResetter(opt.getFieldResetter()); + this.options.add(copy); options.get(options.size() - 1).setParent(this); } private void setOptions(List options) throws OptionParserException { for (ProcessedOption opt : options) { - this.options.add(new ProcessedOption(verifyThatNamesAreUnique(opt.shortName(), opt.name()), opt.name(), + ProcessedOption copy = new ProcessedOption(verifyThatNamesAreUnique(opt.shortName(), opt.name()), opt.name(), opt.description(), opt.getArgument(), opt.isRequired(), opt.getValueSeparator(), opt.askIfNotSet(), opt.acceptNameWithoutDashes(), opt.selectorType(), opt.getDefaultValues(), opt.type(), opt.getFieldName(), opt.getOptionType(), opt.converter(), opt.completer(), opt.validator(), opt.activator(), opt.getRenderer(), opt.parser(), opt.doOverrideRequired(), opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited(), - opt.getDescriptionUrl(), opt.isUrl())); + opt.getDescriptionUrl(), opt.isUrl()); + if (opt.getFieldSetter() != null) + copy.setFieldSetter(opt.getFieldSetter()); + if (opt.getFieldResetter() != null) + copy.setFieldResetter(opt.getFieldResetter()); + this.options.add(copy); this.options.get(this.options.size() - 1).setParent(this); } diff --git a/aesh/src/test/java/org/aesh/command/populator/FieldSetterPopulatorTest.java b/aesh/src/test/java/org/aesh/command/populator/FieldSetterPopulatorTest.java new file mode 100644 index 00000000..3b37a094 --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/populator/FieldSetterPopulatorTest.java @@ -0,0 +1,241 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.aesh.command.populator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.impl.activator.AeshCommandActivatorProvider; +import org.aesh.command.impl.activator.AeshOptionActivatorProvider; +import org.aesh.command.impl.completer.AeshCompleterInvocationProvider; +import org.aesh.command.impl.converter.AeshConverterInvocationProvider; +import org.aesh.command.impl.internal.OptionType; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.internal.ProcessedCommandBuilder; +import org.aesh.command.impl.internal.ProcessedOptionBuilder; +import org.aesh.command.impl.invocation.AeshInvocationProviders; +import org.aesh.command.impl.parser.CommandLineParser; +import org.aesh.command.impl.parser.CommandLineParserBuilder; +import org.aesh.command.impl.validator.AeshValidatorInvocationProvider; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.invocation.InvocationProviders; +import org.aesh.command.option.Option; +import org.aesh.command.settings.SettingsBuilder; +import org.aesh.console.AeshContext; +import org.junit.Test; + +/** + * Tests that field setter/resetter lambdas on ProcessedOption work correctly + * with the AeshCommandPopulator, simulating what the annotation processor generates. + */ +public class FieldSetterPopulatorTest { + + private final InvocationProviders invocationProviders = new AeshInvocationProviders( + SettingsBuilder.builder() + .converterInvocationProvider(new AeshConverterInvocationProvider()) + .completerInvocationProvider(new AeshCompleterInvocationProvider()) + .validatorInvocationProvider(new AeshValidatorInvocationProvider()) + .optionActivatorProvider(new AeshOptionActivatorProvider()) + .commandActivatorProvider(new AeshCommandActivatorProvider()).build()); + + @CommandDefinition(name = "test", description = "test command") + public static class TestCommand implements Command { + @Option(shortName = 'v', hasValue = false) + boolean verbose; + + @Option(defaultValue = "hello") + String greeting; + + @Option + int count; + + @Override + public CommandResult execute(CommandInvocation invocation) { + return CommandResult.SUCCESS; + } + } + + /** + * Build a ProcessedCommand manually with field setters, like the annotation processor would. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + private CommandLineParser buildParserWithFieldSetters(TestCommand instance) throws Exception { + ProcessedCommand processedCommand = ((ProcessedCommandBuilder) ProcessedCommandBuilder.builder()) + .name("test") + .description("test command") + .command(instance) + .create(); + + processedCommand.addOption( + ProcessedOptionBuilder.builder() + .shortName('v') + .name("verbose") + .type(boolean.class) + .fieldName("verbose") + .optionType(OptionType.BOOLEAN) + .hasValue(false) + .fieldSetter((inst, val) -> ((TestCommand) inst).verbose = (boolean) val) + .fieldResetter(inst -> ((TestCommand) inst).verbose = false) + .build()); + + processedCommand.addOption( + ProcessedOptionBuilder.builder() + .name("greeting") + .type(String.class) + .fieldName("greeting") + .optionType(OptionType.NORMAL) + .addDefaultValue("hello") + .fieldSetter((inst, val) -> ((TestCommand) inst).greeting = (String) val) + .fieldResetter(inst -> ((TestCommand) inst).greeting = null) + .build()); + + processedCommand.addOption( + ProcessedOptionBuilder.builder() + .name("count") + .type(int.class) + .fieldName("count") + .optionType(OptionType.NORMAL) + .fieldSetter((inst, val) -> ((TestCommand) inst).count = (int) val) + .fieldResetter(inst -> ((TestCommand) inst).count = 0) + .build()); + + return CommandLineParserBuilder., CommandInvocation> builder() + .processedCommand(processedCommand) + .create(); + } + + @Test + public void testBooleanFlagWithFieldSetter() throws Exception { + TestCommand cmd = new TestCommand(); + CommandLineParser parser = buildParserWithFieldSetters(cmd); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + parser.parse("test -v"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + + assertTrue("verbose should be true after -v", cmd.verbose); + } + + @Test + public void testBooleanFlagResetWithFieldResetter() throws Exception { + TestCommand cmd = new TestCommand(); + CommandLineParser parser = buildParserWithFieldSetters(cmd); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // First set it + parser.parse("test -v"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertTrue(cmd.verbose); + + // Then parse without -v — should reset + parser.parse("test"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertFalse("verbose should be reset to false", cmd.verbose); + } + + @Test + public void testStringOptionWithFieldSetter() throws Exception { + TestCommand cmd = new TestCommand(); + CommandLineParser parser = buildParserWithFieldSetters(cmd); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + parser.parse("test --greeting=world"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + + assertEquals("world", cmd.greeting); + } + + @Test + public void testDefaultValueAppliedWithFieldSetter() throws Exception { + TestCommand cmd = new TestCommand(); + CommandLineParser parser = buildParserWithFieldSetters(cmd); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // Parse without --greeting — default "hello" should be applied + parser.parse("test"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + + assertEquals("hello", cmd.greeting); + } + + @Test + public void testIntOptionWithFieldSetter() throws Exception { + TestCommand cmd = new TestCommand(); + CommandLineParser parser = buildParserWithFieldSetters(cmd); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + parser.parse("test --count=42"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + + assertEquals(42, cmd.count); + } + + @Test + public void testIntOptionResetWithFieldResetter() throws Exception { + TestCommand cmd = new TestCommand(); + CommandLineParser parser = buildParserWithFieldSetters(cmd); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + parser.parse("test --count=42"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertEquals(42, cmd.count); + + // Reset + parser.parse("test"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertEquals(0, cmd.count); + } + + @Test + public void testStringOptionResetWithFieldResetter() throws Exception { + TestCommand cmd = new TestCommand(); + cmd.greeting = "something"; + CommandLineParser parser = buildParserWithFieldSetters(cmd); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // Parse with --greeting explicitly — should set + parser.parse("test --greeting=world"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertEquals("world", cmd.greeting); + } + + /** + * Verify that field setters from the annotation processor path work with the + * standard AeshCommandContainerBuilder (via MetadataProviderRegistry). + * This uses reflection-based path for comparison. + */ + @Test + public void testReflectionPathStillWorks() throws Exception { + // Use the standard reflection-based builder (no field setters) + TestCommand cmd = new TestCommand(); + CommandLineParser parser = new org.aesh.command.impl.container.AeshCommandContainerBuilder() + .create(cmd).getParser(); + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + parser.parse("test -v --greeting=world --count=5"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + + assertTrue(cmd.verbose); + assertEquals("world", cmd.greeting); + assertEquals(5, cmd.count); + } +} diff --git a/aesh/src/test/java/org/aesh/command/populator/MetadataProviderFieldSetterTest.java b/aesh/src/test/java/org/aesh/command/populator/MetadataProviderFieldSetterTest.java new file mode 100644 index 00000000..06d7903a --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/populator/MetadataProviderFieldSetterTest.java @@ -0,0 +1,183 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.aesh.command.populator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.impl.activator.AeshCommandActivatorProvider; +import org.aesh.command.impl.activator.AeshOptionActivatorProvider; +import org.aesh.command.impl.completer.AeshCompleterInvocationProvider; +import org.aesh.command.impl.container.AeshCommandContainerBuilder; +import org.aesh.command.impl.converter.AeshConverterInvocationProvider; +import org.aesh.command.impl.internal.OptionType; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.internal.ProcessedCommandBuilder; +import org.aesh.command.impl.internal.ProcessedOption; +import org.aesh.command.impl.internal.ProcessedOptionBuilder; +import org.aesh.command.impl.invocation.AeshInvocationProviders; +import org.aesh.command.impl.parser.CommandLineParser; +import org.aesh.command.impl.validator.AeshValidatorInvocationProvider; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.invocation.InvocationProviders; +import org.aesh.command.metadata.CommandMetadataProvider; +import org.aesh.command.option.Option; +import org.aesh.command.parser.CommandLineParserException; +import org.aesh.command.settings.SettingsBuilder; +import org.aesh.console.AeshContext; +import org.junit.Test; + +/** + * Tests the full flow: MetadataProvider -> AeshCommandContainerBuilder -> populator + * with field setters, simulating what happens in a real application. + */ +public class MetadataProviderFieldSetterTest { + + private final InvocationProviders invocationProviders = new AeshInvocationProviders( + SettingsBuilder.builder() + .converterInvocationProvider(new AeshConverterInvocationProvider()) + .completerInvocationProvider(new AeshCompleterInvocationProvider()) + .validatorInvocationProvider(new AeshValidatorInvocationProvider()) + .optionActivatorProvider(new AeshOptionActivatorProvider()) + .commandActivatorProvider(new AeshCommandActivatorProvider()).build()); + + @CommandDefinition(name = "mytest", description = "test") + public static class MyTestCommand implements Command { + @Option(shortName = 'v', hasValue = false) + boolean verbose; + + @Option(defaultValue = "WARN") + String level; + + @Override + public CommandResult execute(CommandInvocation invocation) { + return CommandResult.SUCCESS; + } + } + + /** + * A manually created metadata provider, equivalent to what the annotation processor generates. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static class MyTestCommand_AeshMetadata implements CommandMetadataProvider { + @Override + public Class commandType() { + return MyTestCommand.class; + } + + @Override + public MyTestCommand newInstance() { + return new MyTestCommand(); + } + + @Override + public boolean isGroupCommand() { + return false; + } + + @Override + public Class[] groupCommandClasses() { + return new Class[0]; + } + + @Override + public ProcessedCommand buildProcessedCommand(MyTestCommand instance) throws CommandLineParserException { + try { + ProcessedCommand processedCommand = ((ProcessedCommandBuilder) ProcessedCommandBuilder.builder()) + .name("mytest") + .description("test") + .command(instance) + .create(); + + processedCommand.addOption( + ProcessedOptionBuilder.builder() + .shortName('v') + .name("verbose") + .type(boolean.class) + .fieldName("verbose") + .optionType(OptionType.BOOLEAN) + .hasValue(false) + .fieldSetter((inst, val) -> ((MyTestCommand) inst).verbose = (boolean) val) + .fieldResetter(inst -> ((MyTestCommand) inst).verbose = false) + .build()); + + processedCommand.addOption( + ProcessedOptionBuilder.builder() + .name("level") + .type(String.class) + .fieldName("level") + .optionType(OptionType.NORMAL) + .addDefaultValue("WARN") + .fieldSetter((inst, val) -> ((MyTestCommand) inst).level = (String) val) + .fieldResetter(inst -> ((MyTestCommand) inst).level = null) + .build()); + + return processedCommand; + } catch (Exception e) { + throw new CommandLineParserException(e.getMessage()); + } + } + } + + @Test + public void testMetadataProviderWithFieldSetters() throws Exception { + // Manually register the provider (in real apps, ServiceLoader does this) + // We'll create the container directly via the provider + MyTestCommand cmd = new MyTestCommand(); + MyTestCommand_AeshMetadata provider = new MyTestCommand_AeshMetadata(); + + ProcessedCommand processedCommand = provider.buildProcessedCommand(cmd); + + // Verify field setters are present + assertNotNull("verbose setter should exist", + ((ProcessedOption) processedCommand.getOptions().get(0)).getFieldSetter()); + assertNotNull("level setter should exist", + ((ProcessedOption) processedCommand.getOptions().get(1)).getFieldSetter()); + } + + @Test + public void testContainerBuilderUsesMetadataProvider() throws Exception { + // Register our provider so AeshCommandContainerBuilder finds it + // We need to use ServiceLoader normally, but for testing we can build directly + MyTestCommand cmd = new MyTestCommand(); + MyTestCommand_AeshMetadata provider = new MyTestCommand_AeshMetadata(); + + ProcessedCommand processedCommand = provider.buildProcessedCommand(cmd); + + CommandLineParser parser = org.aesh.command.impl.parser.CommandLineParserBuilder + ., CommandInvocation> builder() + .processedCommand(processedCommand) + .create(); + + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + + // Test boolean flag + parser.parse("mytest -v"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertTrue("verbose should be true", cmd.verbose); + + // Test default value + parser.parse("mytest"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertFalse("verbose should be reset", cmd.verbose); + assertEquals("WARN", cmd.level); + + // Test explicit value + parser.parse("mytest --level=ERROR"); + parser.getCommandPopulator().populateObject(parser.getProcessedCommand(), invocationProviders, aeshContext, + CommandLineParser.Mode.VALIDATE); + assertEquals("ERROR", cmd.level); + } +}