From e6583514b4506cfebe1ed0ad40ae4e592e82a279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Pedersen?= Date: Thu, 12 Mar 2026 13:09:54 +0100 Subject: [PATCH] Add compile-time annotation processor to reduce runtime reflection Introduces aesh-processor module with a JSR 269 annotation processor that generates CommandMetadataProvider implementations for @CommandDefinition and @GroupCommandDefinition classes. Generated code uses ProcessedCommandBuilder/ ProcessedOptionBuilder with literal values and direct new X() calls, eliminating annotation scanning and reflective instantiation at runtime. Dual-mode: if generated providers are on the classpath (via ServiceLoader), AeshCommandContainerBuilder uses them; otherwise it falls back to the existing reflection path with zero behavior change for existing users. --- README.asciidoc | 17 + aesh-processor/pom.xml | 91 +++ .../processor/AeshAnnotationProcessor.java | 259 +++++++ .../org/aesh/processor/CodeGenerator.java | 659 ++++++++++++++++++ .../javax.annotation.processing.Processor | 1 + .../processor/ProcessorBenchmarkTest.java | 323 +++++++++ .../org/aesh/processor/ProcessorTest.java | 582 ++++++++++++++++ .../AeshCommandContainerBuilder.java | 43 ++ .../metadata/CommandMetadataProvider.java | 68 ++ .../metadata/MetadataProviderRegistry.java | 84 +++ pom.xml | 1 + 11 files changed, 2128 insertions(+) create mode 100644 aesh-processor/pom.xml create mode 100644 aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java create mode 100644 aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java create mode 100644 aesh-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor create mode 100644 aesh-processor/src/test/java/org/aesh/processor/ProcessorBenchmarkTest.java create mode 100644 aesh-processor/src/test/java/org/aesh/processor/ProcessorTest.java create mode 100644 aesh/src/main/java/org/aesh/command/metadata/CommandMetadataProvider.java create mode 100644 aesh/src/main/java/org/aesh/command/metadata/MetadataProviderRegistry.java diff --git a/README.asciidoc b/README.asciidoc index cce635d6..7ffbe059 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -27,6 +27,7 @@ Aesh is a Java library for building interactive CLI applications with commands, * Group commands and sub-command mode (e.g. `git rebase`, `git pull`) * Custom validators, activators, completers, converters, and renderers * Auto-generated help text from command metadata +* Compile-time annotation processor for reflection-free command metadata (faster startup, GraalVM-friendly) * Add and remove commands at runtime * Terminal graphics utilities: progress bar, table, tree display * Line editing, history, undo/redo, paste buffer @@ -34,6 +35,22 @@ Aesh is a Java library for building interactive CLI applications with commands, * Masking, redirect, alias, pipeline support * Works on POSIX systems and Windows +== Annotation Processor (Optional) + +Add the `aesh-processor` dependency to generate command metadata at compile time, eliminating runtime reflection for annotation scanning and improving startup time. This is especially useful for GraalVM native images. + +[source,xml] +---- + + org.aesh + aesh-processor + 3.0 + provided + +---- + +No code changes required -- if the generated metadata is on the classpath, Aesh uses it automatically. Otherwise it falls back to the existing reflection-based approach. + == Quick Start [source,java] diff --git a/aesh-processor/pom.xml b/aesh-processor/pom.xml new file mode 100644 index 00000000..f22b5392 --- /dev/null +++ b/aesh-processor/pom.xml @@ -0,0 +1,91 @@ + + + + + + + org.aesh + aesh-all + 3.4-dev + + 4.0.0 + + org.aesh + aesh-processor + jar + 3.4-dev + Æsh Annotation Processor + Compile-time annotation processor for Æsh commands + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0 + + + + + + org.aesh + aesh + ${project.version} + + + + junit + junit + 4.13.1 + test + + + + org.aesh + aesh + ${project.version} + test-jar + test + + + + + + + maven-compiler-plugin + + + none + + + + maven-surefire-plugin + + true + false + + **/*TestCase.java + **/*Test.java + + + + + + + diff --git a/aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java b/aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java new file mode 100644 index 00000000..641514b2 --- /dev/null +++ b/aesh-processor/src/main/java/org/aesh/processor/AeshAnnotationProcessor.java @@ -0,0 +1,259 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.processor; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.MirroredTypeException; +import javax.lang.model.type.MirroredTypesException; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.Diagnostic; +import javax.tools.JavaFileObject; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.GroupCommandDefinition; + +/** + * JSR 269 annotation processor that generates {@code CommandMetadataProvider} + * implementations for classes annotated with {@link CommandDefinition} or + * {@link GroupCommandDefinition}. + * + * @author Aesh team + */ +@SupportedAnnotationTypes({ + "org.aesh.command.CommandDefinition", + "org.aesh.command.GroupCommandDefinition" +}) +@SupportedSourceVersion(SourceVersion.RELEASE_8) +public class AeshAnnotationProcessor extends AbstractProcessor { + + private Filer filer; + private Messager messager; + private Elements elementUtils; + private Types typeUtils; + private final List generatedProviders = new ArrayList<>(); + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.filer = processingEnv.getFiler(); + this.messager = processingEnv.getMessager(); + this.elementUtils = processingEnv.getElementUtils(); + this.typeUtils = processingEnv.getTypeUtils(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + if (roundEnv.processingOver()) { + if (!generatedProviders.isEmpty()) { + writeServiceFile(); + } + return false; + } + + Set commandElements = new LinkedHashSet<>(); + + for (Element element : roundEnv.getElementsAnnotatedWith(CommandDefinition.class)) { + if (element.getKind() == ElementKind.CLASS) { + commandElements.add((TypeElement) element); + } + } + + for (Element element : roundEnv.getElementsAnnotatedWith(GroupCommandDefinition.class)) { + if (element.getKind() == ElementKind.CLASS) { + commandElements.add((TypeElement) element); + } + } + + for (TypeElement commandElement : commandElements) { + if (!validate(commandElement)) { + continue; + } + try { + generateProvider(commandElement); + } catch (IOException e) { + messager.printMessage(Diagnostic.Kind.ERROR, + "Failed to generate metadata provider: " + e.getMessage(), commandElement); + } + } + + return false; + } + + private boolean validate(TypeElement element) { + boolean valid = true; + + if (element.getModifiers().contains(Modifier.ABSTRACT)) { + messager.printMessage(Diagnostic.Kind.ERROR, + "Command class must not be abstract", element); + valid = false; + } + + TypeMirror commandType = elementUtils.getTypeElement(Command.class.getCanonicalName()).asType(); + if (!typeUtils.isAssignable(typeUtils.erasure(element.asType()), typeUtils.erasure(commandType))) { + messager.printMessage(Diagnostic.Kind.ERROR, + "Command class must implement org.aesh.command.Command", element); + valid = false; + } + + boolean hasNoArgConstructor = false; + for (Element enclosed : element.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.CONSTRUCTOR) { + javax.lang.model.element.ExecutableElement constructor = + (javax.lang.model.element.ExecutableElement) enclosed; + if (constructor.getParameters().isEmpty() + && !constructor.getModifiers().contains(Modifier.PRIVATE)) { + hasNoArgConstructor = true; + break; + } + } + } + if (!hasNoArgConstructor) { + messager.printMessage(Diagnostic.Kind.ERROR, + "Command class must have an accessible no-arg constructor", element); + valid = false; + } + + // Validate field types + for (VariableElement field : collectFields(element)) { + validateField(field); + } + + return valid; + } + + private void validateField(VariableElement field) { + if (field.getAnnotation(org.aesh.command.option.OptionList.class) != null) { + TypeMirror collectionType = elementUtils + .getTypeElement(Collection.class.getCanonicalName()).asType(); + if (!typeUtils.isAssignable(typeUtils.erasure(field.asType()), + typeUtils.erasure(collectionType))) { + messager.printMessage(Diagnostic.Kind.ERROR, + "OptionList field must be instance of Collection", field); + } + } + if (field.getAnnotation(org.aesh.command.option.Arguments.class) != null) { + TypeMirror collectionType = elementUtils + .getTypeElement(Collection.class.getCanonicalName()).asType(); + if (!typeUtils.isAssignable(typeUtils.erasure(field.asType()), + typeUtils.erasure(collectionType))) { + messager.printMessage(Diagnostic.Kind.ERROR, + "Arguments field must be instance of Collection", field); + } + } + if (field.getAnnotation(org.aesh.command.option.OptionGroup.class) != null) { + TypeMirror mapType = elementUtils + .getTypeElement(Map.class.getCanonicalName()).asType(); + if (!typeUtils.isAssignable(typeUtils.erasure(field.asType()), + typeUtils.erasure(mapType))) { + messager.printMessage(Diagnostic.Kind.ERROR, + "OptionGroup field must be instance of Map", field); + } + } + } + + private List collectFields(TypeElement element) { + List fields = new ArrayList<>(); + collectFieldsRecursive(element, fields); + return fields; + } + + private void collectFieldsRecursive(TypeElement element, List fields) { + for (Element enclosed : element.getEnclosedElements()) { + if (enclosed.getKind() == ElementKind.FIELD) { + fields.add((VariableElement) enclosed); + } + } + TypeMirror superclass = element.getSuperclass(); + if (superclass.getKind() != TypeKind.NONE) { + Element superElement = typeUtils.asElement(superclass); + if (superElement instanceof TypeElement) { + collectFieldsRecursive((TypeElement) superElement, fields); + } + } + } + + 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 simpleName = commandElement.getSimpleName().toString(); + String metadataClassName = simpleName + "_AeshMetadata"; + String fullMetadataName = packageName.isEmpty() ? metadataClassName : packageName + "." + metadataClassName; + + boolean isGroup = commandElement.getAnnotation(GroupCommandDefinition.class) != null; + + List fields = collectFields(commandElement); + + String code = CodeGenerator.generate( + packageName, simpleName, metadataClassName, qualifiedName, + commandElement, fields, isGroup, elementUtils, typeUtils); + + JavaFileObject sourceFile = filer.createSourceFile(fullMetadataName, commandElement); + try (Writer writer = sourceFile.openWriter()) { + writer.write(code); + } + + generatedProviders.add(fullMetadataName); + } + + private void writeServiceFile() { + try { + javax.tools.FileObject serviceFile = filer.createResource( + javax.tools.StandardLocation.CLASS_OUTPUT, "", + "META-INF/services/org.aesh.command.metadata.CommandMetadataProvider"); + try (Writer writer = serviceFile.openWriter()) { + for (String provider : generatedProviders) { + writer.write(provider); + writer.write("\n"); + } + } + } catch (IOException e) { + messager.printMessage(Diagnostic.Kind.ERROR, + "Failed to write ServiceLoader file: " + e.getMessage()); + } + } +} diff --git a/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java b/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java new file mode 100644 index 00000000..34f332e8 --- /dev/null +++ b/aesh-processor/src/main/java/org/aesh/processor/CodeGenerator.java @@ -0,0 +1,659 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.processor; + +import java.util.List; + +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; + +import org.aesh.command.CommandDefinition; +import org.aesh.command.GroupCommandDefinition; +import org.aesh.command.option.Argument; +import org.aesh.command.option.Arguments; +import org.aesh.command.option.Option; +import org.aesh.command.option.OptionGroup; +import org.aesh.command.option.OptionList; + +/** + * Generates Java source code for {@code CommandMetadataProvider} implementations. + * + * @author Aesh team + */ +final class CodeGenerator { + + // Sentinel class names — when annotation value equals these, skip the builder call + private static final String NULL_COMMAND_VALIDATOR = "org.aesh.command.impl.validator.NullCommandValidator"; + private static final String NULL_RESULT_HANDLER = "org.aesh.command.impl.result.NullResultHandler"; + private static final String NULL_COMMAND_ACTIVATOR = "org.aesh.command.impl.activator.NullCommandActivator"; + private static final String NULL_OPTION_COMPLETER = "org.aesh.command.impl.completer.NullOptionCompleter"; + private static final String NULL_CONVERTER = "org.aesh.command.impl.converter.NullConverter"; + private static final String NULL_VALIDATOR = "org.aesh.command.impl.validator.NullValidator"; + private static final String NULL_ACTIVATOR = "org.aesh.command.impl.activator.NullActivator"; + private static final String NULL_OPTION_RENDERER = "org.aesh.command.impl.renderer.NullOptionRenderer"; + private static final String AESH_OPTION_PARSER = "org.aesh.command.impl.parser.AeshOptionParser"; + + private CodeGenerator() { + } + + static String generate( + String packageName, String simpleName, String metadataClassName, + String qualifiedCommandName, TypeElement commandElement, + List fields, boolean isGroup, + Elements elementUtils, Types typeUtils) { + + StringBuilder sb = new StringBuilder(); + + // Package + if (!packageName.isEmpty()) { + sb.append("package ").append(packageName).append(";\n\n"); + } + + // Imports + sb.append("import java.util.Arrays;\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"); + sb.append("import org.aesh.command.impl.internal.ProcessedOptionBuilder;\n"); + sb.append("import org.aesh.command.impl.internal.OptionType;\n"); + sb.append("import org.aesh.command.metadata.CommandMetadataProvider;\n"); + sb.append("import org.aesh.command.parser.CommandLineParserException;\n\n"); + + // Class declaration + sb.append("/**\n"); + sb.append(" * Generated compile-time metadata provider for {@link ").append(qualifiedCommandName).append("}.\n"); + sb.append(" * Do not edit — this file is regenerated by the aesh annotation processor.\n"); + sb.append(" */\n"); + sb.append("@SuppressWarnings({\"unchecked\", \"rawtypes\"})\n"); + sb.append("public final class ").append(metadataClassName); + sb.append(" implements CommandMetadataProvider<").append(simpleName).append("> {\n\n"); + + // commandType() + sb.append(" @Override\n"); + sb.append(" public Class<").append(simpleName).append("> commandType() {\n"); + sb.append(" return ").append(simpleName).append(".class;\n"); + sb.append(" }\n\n"); + + // newInstance() + sb.append(" @Override\n"); + sb.append(" public ").append(simpleName).append(" newInstance() {\n"); + sb.append(" return new ").append(simpleName).append("();\n"); + sb.append(" }\n\n"); + + // isGroupCommand() + sb.append(" @Override\n"); + sb.append(" public boolean isGroupCommand() {\n"); + sb.append(" return ").append(isGroup).append(";\n"); + sb.append(" }\n\n"); + + // groupCommandClasses() + sb.append(" @Override\n"); + sb.append(" public Class[] groupCommandClasses() {\n"); + if (isGroup) { + generateGroupCommandClasses(sb, commandElement, elementUtils); + } else { + sb.append(" return new Class[0];\n"); + } + sb.append(" }\n\n"); + + // buildProcessedCommand() + 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); + sb.append(" }\n"); + + // End class + sb.append("}\n"); + + return sb.toString(); + } + + private static void generateGroupCommandClasses(StringBuilder sb, TypeElement commandElement, Elements elementUtils) { + // We need to extract groupCommands() from @GroupCommandDefinition via annotation mirror + // because accessing it directly would trigger MirroredTypesException at compile time. + List groupClasses = getGroupCommandClassNames(commandElement, elementUtils); + if (groupClasses.isEmpty()) { + sb.append(" return new Class[0];\n"); + } else { + sb.append(" return new Class[] {\n"); + for (int i = 0; i < groupClasses.size(); i++) { + sb.append(" ").append(groupClasses.get(i)).append(".class"); + if (i < groupClasses.size() - 1) sb.append(","); + sb.append("\n"); + } + sb.append(" };\n"); + } + } + + @SuppressWarnings("unchecked") + private static List getGroupCommandClassNames(TypeElement element, Elements elementUtils) { + List classNames = new java.util.ArrayList<>(); + for (AnnotationMirror mirror : element.getAnnotationMirrors()) { + String annotationType = ((TypeElement) mirror.getAnnotationType().asElement()) + .getQualifiedName().toString(); + if (annotationType.equals(GroupCommandDefinition.class.getCanonicalName())) { + for (java.util.Map.Entry entry : + mirror.getElementValues().entrySet()) { + if (entry.getKey().getSimpleName().toString().equals("groupCommands")) { + List values = + (List) entry.getValue().getValue(); + for (AnnotationValue av : values) { + TypeMirror tm = (TypeMirror) av.getValue(); + classNames.add(tm.toString()); + } + } + } + } + } + return classNames; + } + + private static void generateBuildProcessedCommand(StringBuilder sb, TypeElement commandElement, + List fields, boolean isGroup, + Elements elementUtils, Types typeUtils) { + + sb.append(" ProcessedCommand processedCommand = ProcessedCommandBuilder.builder()\n"); + + if (isGroup) { + GroupCommandDefinition gcd = commandElement.getAnnotation(GroupCommandDefinition.class); + sb.append(" .name(").append(stringLiteral(gcd.name())).append(")\n"); + generateCommandActivator(sb, commandElement, isGroup, elementUtils); + sb.append(" .aliases(Arrays.asList(").append(stringArrayLiteral(gcd.aliases())).append("))\n"); + sb.append(" .description(").append(stringLiteral(gcd.description())).append(")\n"); + generateCommandValidator(sb, commandElement, isGroup, elementUtils); + sb.append(" .command(instance)\n"); + sb.append(" .generateHelp(").append(gcd.generateHelp()).append(")\n"); + sb.append(" .version(").append(stringLiteral(gcd.version())).append(")\n"); + generateResultHandler(sb, commandElement, isGroup, elementUtils); + sb.append(" .helpUrl(").append(stringLiteral(gcd.helpUrl())).append(")\n"); + } else { + CommandDefinition cd = commandElement.getAnnotation(CommandDefinition.class); + sb.append(" .name(").append(stringLiteral(cd.name())).append(")\n"); + generateCommandActivator(sb, commandElement, isGroup, elementUtils); + sb.append(" .aliases(Arrays.asList(").append(stringArrayLiteral(cd.aliases())).append("))\n"); + sb.append(" .description(").append(stringLiteral(cd.description())).append(")\n"); + generateCommandValidator(sb, commandElement, isGroup, elementUtils); + sb.append(" .command(instance)\n"); + generateResultHandler(sb, commandElement, isGroup, elementUtils); + sb.append(" .generateHelp(").append(cd.generateHelp()).append(")\n"); + sb.append(" .disableParsing(").append(cd.disableParsing()).append(")\n"); + sb.append(" .version(").append(stringLiteral(cd.version())).append(")\n"); + sb.append(" .helpUrl(").append(stringLiteral(cd.helpUrl())).append(")\n"); + } + + sb.append(" .create();\n\n"); + + // Process fields + for (VariableElement field : fields) { + generateFieldProcessing(sb, field, elementUtils, typeUtils); + } + + sb.append(" return processedCommand;\n"); + } + + private static void generateCommandValidator(StringBuilder sb, TypeElement element, boolean isGroup, + Elements elementUtils) { + String validatorClass = getAnnotationClassValue(element, + isGroup ? GroupCommandDefinition.class.getCanonicalName() : CommandDefinition.class.getCanonicalName(), + "validator", elementUtils); + if (validatorClass != null && !validatorClass.equals(NULL_COMMAND_VALIDATOR)) { + sb.append(" .validator(new ").append(validatorClass).append("())\n"); + } + } + + private static void generateResultHandler(StringBuilder sb, TypeElement element, boolean isGroup, + Elements elementUtils) { + String handlerClass = getAnnotationClassValue(element, + isGroup ? GroupCommandDefinition.class.getCanonicalName() : CommandDefinition.class.getCanonicalName(), + "resultHandler", elementUtils); + if (handlerClass != null && !handlerClass.equals(NULL_RESULT_HANDLER)) { + sb.append(" .resultHandler(new ").append(handlerClass).append("())\n"); + } + } + + private static void generateCommandActivator(StringBuilder sb, TypeElement element, boolean isGroup, + Elements elementUtils) { + String activatorClass = getAnnotationClassValue(element, + isGroup ? GroupCommandDefinition.class.getCanonicalName() : CommandDefinition.class.getCanonicalName(), + "activator", elementUtils); + if (activatorClass != null && !activatorClass.equals(NULL_COMMAND_ACTIVATOR)) { + sb.append(" .activator(new ").append(activatorClass).append("())\n"); + } + } + + private static void generateFieldProcessing(StringBuilder sb, VariableElement field, + Elements elementUtils, Types typeUtils) { + Option o = field.getAnnotation(Option.class); + OptionList ol = field.getAnnotation(OptionList.class); + OptionGroup og = field.getAnnotation(OptionGroup.class); + Arguments args = field.getAnnotation(Arguments.class); + Argument arg = field.getAnnotation(Argument.class); + + if (o != null) { + generateOption(sb, field, o, elementUtils, typeUtils); + } else if (ol != null) { + generateOptionList(sb, field, ol, elementUtils, typeUtils); + } else if (og != null) { + generateOptionGroup(sb, field, og, elementUtils, typeUtils); + } else if (args != null) { + generateArguments(sb, field, args, elementUtils, typeUtils); + } else if (arg != null) { + generateArgument(sb, field, arg, elementUtils, typeUtils); + } + } + + private static void generateOption(StringBuilder sb, VariableElement field, Option o, + Elements elementUtils, Types typeUtils) { + String fieldName = field.getSimpleName().toString(); + String fieldType = getBoxedTypeName(field.asType(), typeUtils); + boolean isBooleanType = isBooleanType(field.asType(), typeUtils); + + String optionType; + if (o.hasValue()) { + optionType = "OptionType.NORMAL"; + } else { + optionType = "OptionType.BOOLEAN"; + } + + sb.append(" processedCommand.addOption(\n"); + sb.append(" ProcessedOptionBuilder.builder()\n"); + sb.append(" .shortName(").append(charLiteral(o.shortName())).append(")\n"); + sb.append(" .name(").append(stringLiteral(o.name().length() < 1 ? fieldName : o.name())).append(")\n"); + sb.append(" .description(").append(stringLiteral(o.description())).append(")\n"); + sb.append(" .required(").append(o.required()).append(")\n"); + sb.append(" .valueSeparator(' ')\n"); + sb.append(" .askIfNotSet(").append(o.askIfNotSet()).append(")\n"); + sb.append(" .acceptNameWithoutDashes(").append(o.acceptNameWithoutDashes()).append(")\n"); + sb.append(" .selector(").append(selectorLiteral(o.selector())).append(")\n"); + sb.append(" .addAllDefaultValues(").append(stringArrayLiteralAsNew(o.defaultValue())).append(")\n"); + sb.append(" .type(").append(fieldType).append(".class)\n"); + sb.append(" .fieldName(").append(stringLiteral(fieldName)).append(")\n"); + sb.append(" .optionType(").append(optionType).append(")\n"); + generateOptionConverter(sb, field, "converter", elementUtils); + generateOptionCompleter(sb, field, "completer", isBooleanType, isFileOrResourceType(field.asType(), typeUtils), elementUtils); + generateOptionValidator(sb, field, "validator", elementUtils); + generateOptionActivator(sb, field, "activator", elementUtils); + generateOptionRenderer(sb, field, "renderer", elementUtils); + generateOptionParser(sb, field, "parser", elementUtils); + sb.append(" .overrideRequired(").append(o.overrideRequired()).append(")\n"); + sb.append(" .negatable(").append(o.negatable()).append(")\n"); + sb.append(" .negationPrefix(").append(stringLiteral(o.negationPrefix())).append(")\n"); + 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"); + sb.append(" .build());\n\n"); + } + + private static void generateOptionList(StringBuilder sb, VariableElement field, OptionList ol, + Elements elementUtils, Types typeUtils) { + String fieldName = field.getSimpleName().toString(); + String elementType = getGenericTypeArgument(field.asType(), 0, typeUtils); + + sb.append(" processedCommand.addOption(\n"); + sb.append(" ProcessedOptionBuilder.builder()\n"); + sb.append(" .shortName(").append(charLiteral(ol.shortName())).append(")\n"); + sb.append(" .name(").append(stringLiteral(ol.name().length() < 1 ? fieldName : ol.name())).append(")\n"); + sb.append(" .description(").append(stringLiteral(ol.description())).append(")\n"); + sb.append(" .required(").append(ol.required()).append(")\n"); + sb.append(" .valueSeparator(").append(charLiteral(ol.valueSeparator())).append(")\n"); + sb.append(" .askIfNotSet(").append(ol.askIfNotSet()).append(")\n"); + sb.append(" .selector(").append(selectorLiteral(ol.selector())).append(")\n"); + sb.append(" .addAllDefaultValues(").append(stringArrayLiteralAsNew(ol.defaultValue())).append(")\n"); + sb.append(" .type(").append(elementType).append(".class)\n"); + sb.append(" .fieldName(").append(stringLiteral(fieldName)).append(")\n"); + sb.append(" .optionType(OptionType.LIST)\n"); + generateOptionConverter(sb, field, "converter", elementUtils); + generateOptionCompleter(sb, field, "completer", false, false, elementUtils); + generateOptionValidator(sb, field, "validator", elementUtils); + generateOptionActivator(sb, field, "activator", elementUtils); + generateOptionRenderer(sb, field, "renderer", elementUtils); + generateOptionParser(sb, field, "parser", elementUtils); + sb.append(" .build());\n\n"); + } + + private static void generateOptionGroup(StringBuilder sb, VariableElement field, OptionGroup og, + Elements elementUtils, Types typeUtils) { + String fieldName = field.getSimpleName().toString(); + // For Map, extract V (index 1) + String valueType = getGenericTypeArgument(field.asType(), 1, typeUtils); + + sb.append(" processedCommand.addOption(\n"); + sb.append(" ProcessedOptionBuilder.builder()\n"); + sb.append(" .shortName(").append(charLiteral(og.shortName())).append(")\n"); + sb.append(" .name(").append(stringLiteral(og.name().length() < 1 ? fieldName : og.name())).append(")\n"); + sb.append(" .description(").append(stringLiteral(og.description())).append(")\n"); + sb.append(" .required(").append(og.required()).append(")\n"); + sb.append(" .valueSeparator(',')\n"); + sb.append(" .askIfNotSet(").append(og.askIfNotSet()).append(")\n"); + sb.append(" .addAllDefaultValues(").append(stringArrayLiteralAsNew(og.defaultValue())).append(")\n"); + sb.append(" .type(").append(valueType).append(".class)\n"); + sb.append(" .fieldName(").append(stringLiteral(fieldName)).append(")\n"); + sb.append(" .optionType(OptionType.GROUP)\n"); + generateOptionConverter(sb, field, "converter", elementUtils); + generateOptionCompleter(sb, field, "completer", false, false, elementUtils); + generateOptionValidator(sb, field, "validator", elementUtils); + generateOptionActivator(sb, field, "activator", elementUtils); + generateOptionRenderer(sb, field, "renderer", elementUtils); + generateOptionParser(sb, field, "parser", elementUtils); + sb.append(" .build());\n\n"); + } + + private static void generateArguments(StringBuilder sb, VariableElement field, Arguments a, + Elements elementUtils, Types typeUtils) { + String fieldName = field.getSimpleName().toString(); + String elementType = getGenericTypeArgument(field.asType(), 0, typeUtils); + + sb.append(" processedCommand.setArguments(\n"); + sb.append(" ProcessedOptionBuilder.builder()\n"); + sb.append(" .shortName('\\u0000')\n"); + sb.append(" .name(\"\")\n"); + sb.append(" .description(").append(stringLiteral(a.description())).append(")\n"); + sb.append(" .required(").append(a.required()).append(")\n"); + sb.append(" .valueSeparator(").append(charLiteral(a.valueSeparator())).append(")\n"); + sb.append(" .selector(").append(selectorLiteral(a.selector())).append(")\n"); + sb.append(" .askIfNotSet(").append(a.askIfNotSet()).append(")\n"); + sb.append(" .addAllDefaultValues(").append(stringArrayLiteralAsNew(a.defaultValue())).append(")\n"); + sb.append(" .type(").append(elementType).append(".class)\n"); + sb.append(" .fieldName(").append(stringLiteral(fieldName)).append(")\n"); + sb.append(" .optionType(OptionType.ARGUMENTS)\n"); + generateOptionConverter(sb, field, "converter", elementUtils); + generateOptionCompleter(sb, field, "completer", false, false, elementUtils); + generateOptionValidator(sb, field, "validator", elementUtils); + generateOptionActivator(sb, field, "activator", elementUtils); + generateOptionParser(sb, field, "parser", elementUtils); + sb.append(" .url(").append(a.url()).append(")\n"); + sb.append(" .build());\n\n"); + } + + private static void generateArgument(StringBuilder sb, VariableElement field, Argument arg, + Elements elementUtils, Types typeUtils) { + String fieldName = field.getSimpleName().toString(); + String fieldType = getBoxedTypeName(field.asType(), typeUtils); + + sb.append(" processedCommand.setArgument(\n"); + sb.append(" ProcessedOptionBuilder.builder()\n"); + sb.append(" .shortName('\\u0000')\n"); + sb.append(" .name(\"\")\n"); + sb.append(" .description(").append(stringLiteral(arg.description())).append(")\n"); + sb.append(" .required(").append(arg.required()).append(")\n"); + sb.append(" .valueSeparator(' ')\n"); + sb.append(" .askIfNotSet(").append(arg.askIfNotSet()).append(")\n"); + sb.append(" .selector(").append(selectorLiteral(arg.selector())).append(")\n"); + sb.append(" .addAllDefaultValues(").append(stringArrayLiteralAsNew(arg.defaultValue())).append(")\n"); + sb.append(" .type(").append(fieldType).append(".class)\n"); + sb.append(" .fieldName(").append(stringLiteral(fieldName)).append(")\n"); + sb.append(" .optionType(OptionType.ARGUMENT)\n"); + generateOptionConverter(sb, field, "converter", elementUtils); + generateOptionCompleter(sb, field, "completer", false, isFileOrResourceType(field.asType(), typeUtils), elementUtils); + generateOptionValidator(sb, field, "validator", elementUtils); + generateOptionActivator(sb, field, "activator", elementUtils); + generateOptionRenderer(sb, field, "renderer", elementUtils); + generateOptionParser(sb, field, "parser", elementUtils); + sb.append(" .overrideRequired(").append(arg.overrideRequired()).append(")\n"); + sb.append(" .inherited(").append(arg.inherited()).append(")\n"); + sb.append(" .url(").append(arg.url()).append(")\n"); + sb.append(" .build());\n\n"); + } + + // --- Helper methods for generating option component instantiation --- + + private static void generateOptionConverter(StringBuilder sb, VariableElement field, + String attributeName, Elements elementUtils) { + String className = getFieldAnnotationClassValue(field, attributeName, elementUtils); + if (className != null && !className.equals(NULL_CONVERTER)) { + sb.append(" .converter(new ").append(className).append("())\n"); + } else { + // Pass the Class so the builder can resolve via CLConverterManager + sb.append(" .converter(").append(className != null ? className : NULL_CONVERTER).append(".class)\n"); + } + } + + private static void generateOptionCompleter(StringBuilder sb, VariableElement field, + String attributeName, boolean isBooleanType, boolean isFileOrResource, Elements elementUtils) { + String className = getFieldAnnotationClassValue(field, attributeName, elementUtils); + if (className != null && !className.equals(NULL_OPTION_COMPLETER)) { + sb.append(" .completer(new ").append(className).append("())\n"); + } else if (isBooleanType) { + sb.append(" .completer(new org.aesh.command.impl.completer.BooleanOptionCompleter())\n"); + } else if (isFileOrResource) { + sb.append(" .completer(new org.aesh.command.impl.completer.FileOptionCompleter())\n"); + } else { + sb.append(" .completer(").append(NULL_OPTION_COMPLETER).append(".class)\n"); + } + } + + private static void generateOptionValidator(StringBuilder sb, VariableElement field, + String attributeName, Elements elementUtils) { + String className = getFieldAnnotationClassValue(field, attributeName, elementUtils); + if (className != null && !className.equals(NULL_VALIDATOR)) { + sb.append(" .validator(new ").append(className).append("())\n"); + } else { + sb.append(" .validator(").append(NULL_VALIDATOR).append(".class)\n"); + } + } + + private static void generateOptionActivator(StringBuilder sb, VariableElement field, + String attributeName, Elements elementUtils) { + String className = getFieldAnnotationClassValue(field, attributeName, elementUtils); + if (className != null && !className.equals(NULL_ACTIVATOR)) { + sb.append(" .activator(new ").append(className).append("())\n"); + } else { + sb.append(" .activator(").append(NULL_ACTIVATOR).append(".class)\n"); + } + } + + private static void generateOptionRenderer(StringBuilder sb, VariableElement field, + String attributeName, Elements elementUtils) { + String className = getFieldAnnotationClassValue(field, attributeName, elementUtils); + if (className != null && !className.equals(NULL_OPTION_RENDERER)) { + sb.append(" .renderer(new ").append(className).append("())\n"); + } else { + sb.append(" .renderer(").append(NULL_OPTION_RENDERER).append(".class)\n"); + } + } + + private static void generateOptionParser(StringBuilder sb, VariableElement field, + String attributeName, Elements elementUtils) { + String className = getFieldAnnotationClassValue(field, attributeName, elementUtils); + if (className != null && !className.equals(AESH_OPTION_PARSER)) { + sb.append(" .parser(new ").append(className).append("())\n"); + } else { + sb.append(" .parser(").append(AESH_OPTION_PARSER).append(".class)\n"); + } + } + + // --- Annotation class value extraction (handles MirroredTypeException) --- + + /** + * Extract the Class value of a named annotation attribute from the field's + * option annotation. Returns the fully-qualified class name, or null if not found. + */ + private static String getFieldAnnotationClassValue(VariableElement field, String attributeName, + Elements elementUtils) { + // Check which annotation is on this field and extract from it + String[] annotationTypes = { + Option.class.getCanonicalName(), + OptionList.class.getCanonicalName(), + OptionGroup.class.getCanonicalName(), + Arguments.class.getCanonicalName(), + Argument.class.getCanonicalName() + }; + + for (AnnotationMirror mirror : field.getAnnotationMirrors()) { + String annotationType = ((TypeElement) mirror.getAnnotationType().asElement()) + .getQualifiedName().toString(); + for (String expected : annotationTypes) { + if (annotationType.equals(expected)) { + return extractClassAttribute(mirror, attributeName); + } + } + } + return null; + } + + private static String getAnnotationClassValue(TypeElement element, String annotationType, + String attributeName, Elements elementUtils) { + for (AnnotationMirror mirror : element.getAnnotationMirrors()) { + String mirrorType = ((TypeElement) mirror.getAnnotationType().asElement()) + .getQualifiedName().toString(); + if (mirrorType.equals(annotationType)) { + return extractClassAttribute(mirror, attributeName); + } + } + return null; + } + + private static String extractClassAttribute(AnnotationMirror mirror, String attributeName) { + for (java.util.Map.Entry entry : + mirror.getElementValues().entrySet()) { + if (entry.getKey().getSimpleName().toString().equals(attributeName)) { + Object value = entry.getValue().getValue(); + if (value instanceof TypeMirror) { + return ((TypeMirror) value).toString(); + } + } + } + return null; + } + + // --- Type utility methods --- + + private static boolean isBooleanType(TypeMirror type, Types typeUtils) { + if (type.getKind() == TypeKind.BOOLEAN) return true; + String name = type.toString(); + return name.equals("java.lang.Boolean"); + } + + private static boolean isFileOrResourceType(TypeMirror type, Types typeUtils) { + String name = type.toString(); + return name.equals("java.io.File") || name.equals("org.aesh.io.Resource"); + } + + private static String getBoxedTypeName(TypeMirror type, Types typeUtils) { + switch (type.getKind()) { + case BOOLEAN: + return "boolean"; + case BYTE: + return "byte"; + case SHORT: + return "short"; + case INT: + return "int"; + case LONG: + return "long"; + case FLOAT: + return "float"; + case DOUBLE: + return "double"; + case CHAR: + return "char"; + default: + return type.toString(); + } + } + + private static String getGenericTypeArgument(TypeMirror type, int index, Types typeUtils) { + if (type instanceof DeclaredType) { + DeclaredType declaredType = (DeclaredType) type; + List typeArgs = declaredType.getTypeArguments(); + if (typeArgs.size() > index) { + return typeArgs.get(index).toString(); + } + } + return "Object"; + } + + // --- String literal helpers --- + + private static String stringLiteral(String value) { + if (value == null) return "null"; + return "\"" + escapeJavaString(value) + "\""; + } + + private static String charLiteral(char value) { + if (value == '\u0000') return "'\\u0000'"; + if (value == '\'') return "'\\''"; + if (value == '\\') return "'\\\\'"; + if (value == '\n') return "'\\n'"; + if (value == '\r') return "'\\r'"; + if (value == '\t') return "'\\t'"; + return "'" + value + "'"; + } + + private static String stringArrayLiteral(String[] values) { + if (values == null || values.length == 0) return ""; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + if (i > 0) sb.append(", "); + sb.append(stringLiteral(values[i])); + } + return sb.toString(); + } + + private static String stringArrayLiteralAsNew(String[] values) { + if (values == null || values.length == 0) { + return "new String[0]"; + } + StringBuilder sb = new StringBuilder("new String[] {"); + for (int i = 0; i < values.length; i++) { + if (i > 0) sb.append(", "); + sb.append(stringLiteral(values[i])); + } + sb.append("}"); + return sb.toString(); + } + + private static String selectorLiteral(org.aesh.selector.SelectorType selectorType) { + return "org.aesh.selector.SelectorType." + selectorType.name(); + } + + private static String escapeJavaString(String s) { + StringBuilder sb = new StringBuilder(); + for (char c : s.toCharArray()) { + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/aesh-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/aesh-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 00000000..0c8d841b --- /dev/null +++ b/aesh-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +org.aesh.processor.AeshAnnotationProcessor diff --git a/aesh-processor/src/test/java/org/aesh/processor/ProcessorBenchmarkTest.java b/aesh-processor/src/test/java/org/aesh/processor/ProcessorBenchmarkTest.java new file mode 100644 index 00000000..5c423b68 --- /dev/null +++ b/aesh-processor/src/test/java/org/aesh/processor/ProcessorBenchmarkTest.java @@ -0,0 +1,323 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * + * 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 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.processor; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import org.aesh.command.Command; +import org.aesh.command.container.CommandContainer; +import org.aesh.command.impl.container.AeshCommandContainer; +import org.aesh.command.impl.container.AeshCommandContainerBuilder; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.parser.CommandLineParserBuilder; +import org.aesh.command.metadata.CommandMetadataProvider; +import org.junit.Test; + +/** + * Benchmark comparing reflection-based vs generated metadata command creation. + *

+ * Not a JMH benchmark (no forking, no blackhole sinks), but provides a + * consistent directional comparison. Results are written to stdout and + * captured in surefire reports. + * + * @author Aesh team + */ +public class ProcessorBenchmarkTest { + + private static final int WARMUP_ITERATIONS = 2000; + private static final int MEASURED_ITERATIONS = 5000; + + // A command with 14 annotated fields: Options, OptionList, OptionGroup, Argument + private static final String RICH_COMMAND_SOURCE = + "package bench;\n" + + "\n" + + "import java.util.List;\n" + + "import java.util.Map;\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Argument;\n" + + "import org.aesh.command.option.Option;\n" + + "import org.aesh.command.option.OptionList;\n" + + "import org.aesh.command.option.OptionGroup;\n" + + "\n" + + "@CommandDefinition(name = \"rich\", description = \"A command with many options\",\n" + + " aliases = {\"r\", \"rc\"})\n" + + "public class RichCommand implements Command {\n" + + " @Option(shortName = 'V', hasValue = false, description = \"Verbose\")\n" + + " private boolean verbose;\n" + + "\n" + + " @Option(shortName = 'q', hasValue = false, description = \"Quiet\")\n" + + " private boolean quiet;\n" + + "\n" + + " @Option(shortName = 'o', description = \"Output file\", required = true)\n" + + " private String output;\n" + + "\n" + + " @Option(shortName = 'f', description = \"Format\")\n" + + " private String format;\n" + + "\n" + + " @Option(description = \"Timeout in seconds\", defaultValue = \"30\")\n" + + " private int timeout;\n" + + "\n" + + " @Option(description = \"Max retries\", defaultValue = \"3\")\n" + + " private int maxRetries;\n" + + "\n" + + " @Option(description = \"Enable compression\", hasValue = false)\n" + + " private boolean compress;\n" + + "\n" + + " @Option(description = \"Encoding\")\n" + + " private String encoding;\n" + + "\n" + + " @Option(description = \"Log level\", defaultValue = \"INFO\")\n" + + " private String logLevel;\n" + + "\n" + + " @Option(description = \"Config file\")\n" + + " private String config;\n" + + "\n" + + " @OptionList(shortName = 'i', description = \"Include patterns\")\n" + + " private List includes;\n" + + "\n" + + " @OptionList(shortName = 'e', description = \"Exclude patterns\")\n" + + " private List excludes;\n" + + "\n" + + " @OptionGroup(shortName = 'D', description = \"System properties\")\n" + + " private Map properties;\n" + + "\n" + + " @Argument(description = \"Source directory\")\n" + + " private String source;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation ci) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + // A minimal command with a single option + private static final String SIMPLE_COMMAND_SOURCE = + "package bench;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "@CommandDefinition(name = \"simple\", description = \"Simple command\")\n" + + "public class SimpleCommand implements Command {\n" + + " @Option(shortName = 'n', description = \"Name\")\n" + + " private String name;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation ci) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void benchmarkReflectionVsGenerated() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("bench.RichCommand", RICH_COMMAND_SOURCE), + new InMemorySource("bench.SimpleCommand", SIMPLE_COMMAND_SOURCE)); + + if (!result.success) { + System.out.println("Compilation failed: " + result.diagnostics); + return; + } + + Class richClass = result.classLoader.loadClass("bench.RichCommand"); + Class simpleClass = result.classLoader.loadClass("bench.SimpleCommand"); + CommandMetadataProvider richProvider = + (CommandMetadataProvider) result.classLoader.loadClass("bench.RichCommand_AeshMetadata").newInstance(); + CommandMetadataProvider simpleProvider = + (CommandMetadataProvider) result.classLoader.loadClass("bench.SimpleCommand_AeshMetadata").newInstance(); + + System.out.println("=== Benchmark: Rich command (14 annotated fields) ==="); + System.out.println("Warmup: " + WARMUP_ITERATIONS + ", Measured: " + MEASURED_ITERATIONS + " iterations"); + System.out.println(); + benchmarkPair("Rich command", richClass, richProvider); + + System.out.println(); + System.out.println("=== Benchmark: Simple command (1 annotated field) ==="); + benchmarkPair("Simple command", simpleClass, simpleProvider); + + System.out.println(); + System.out.println("=== First-call latency (single invocation, JVM already warm) ==="); + long singleReflNs = timeOnce(() -> createViaReflection(richClass)); + long singleGenNs = timeOnce(() -> createViaProvider(richProvider, richClass)); + System.out.printf(" Reflection: %,d ns%n", singleReflNs); + System.out.printf(" Generated: %,d ns%n", singleGenNs); + } + + @SuppressWarnings("rawtypes") + private void benchmarkPair(String label, Class commandClass, CommandMetadataProvider provider) throws Exception { + // Warmup + measure reflection + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + createViaReflection(commandClass); + } + long reflectionNs = timeIterations(() -> createViaReflection(commandClass)); + + // Warmup + measure generated + for (int i = 0; i < WARMUP_ITERATIONS; i++) { + createViaProvider(provider, commandClass); + } + long generatedNs = timeIterations(() -> createViaProvider(provider, commandClass)); + + double reflAvgUs = (reflectionNs / (double) MEASURED_ITERATIONS) / 1000.0; + double genAvgUs = (generatedNs / (double) MEASURED_ITERATIONS) / 1000.0; + double speedup = reflAvgUs / genAvgUs; + + System.out.printf(" Reflection: %,.1f us/op (total: %,d ms)%n", reflAvgUs, reflectionNs / 1_000_000); + System.out.printf(" Generated: %,.1f us/op (total: %,d ms)%n", genAvgUs, generatedNs / 1_000_000); + System.out.printf(" Speedup: %.2fx%n", speedup); + } + + private long timeIterations(ThrowingRunnable runnable) throws Exception { + long start = System.nanoTime(); + for (int i = 0; i < MEASURED_ITERATIONS; i++) { + runnable.run(); + } + return System.nanoTime() - start; + } + + private long timeOnce(ThrowingRunnable runnable) throws Exception { + long start = System.nanoTime(); + runnable.run(); + return System.nanoTime() - start; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private CommandContainer createViaReflection(Class commandClass) throws Exception { + AeshCommandContainerBuilder builder = new AeshCommandContainerBuilder(); + Command instance = (Command) commandClass.newInstance(); + return builder.create(instance); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private CommandContainer createViaProvider(CommandMetadataProvider provider, Class commandClass) throws Exception { + Command instance = (Command) commandClass.newInstance(); + ProcessedCommand pc = provider.buildProcessedCommand(instance); + return new AeshCommandContainer( + CommandLineParserBuilder.builder() + .processedCommand(pc) + .create()); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } + + // --- In-memory compilation infrastructure --- + + private CompilationResult compileWithProcessor(InMemorySource... sources) throws IOException { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + Path outputDir = Files.createTempDirectory("aesh-benchmark"); + + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, Collections.singletonList(outputDir.toFile())); + + List cpFiles = new ArrayList<>(); + for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + cpFiles.add(new File(entry)); + } + fileManager.setLocation(StandardLocation.CLASS_PATH, cpFiles); + + // Run annotation processor + JavaCompiler.CompilationTask procTask = compiler.getTask( + null, fileManager, diagnostics, + Arrays.asList("-proc:only", "-processor", AeshAnnotationProcessor.class.getName()), + null, Arrays.asList(sources)); + boolean procSuccess = procTask.call(); + + // Collect generated sources + List allSources = new ArrayList<>(Arrays.asList(sources)); + Files.walk(outputDir) + .filter(p -> p.toString().endsWith(".java")) + .forEach(p -> { + try { + String content = new String(Files.readAllBytes(p), StandardCharsets.UTF_8); + String className = outputDir.relativize(p).toString() + .replace(File.separatorChar, '.').replace(".java", ""); + allSources.add(new InMemorySource(className, content)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + + // Compile everything + DiagnosticCollector compileDiagnostics = new DiagnosticCollector<>(); + JavaCompiler.CompilationTask compileTask = compiler.getTask( + null, fileManager, compileDiagnostics, + Arrays.asList("-proc:none"), null, allSources); + boolean compileSuccess = compileTask.call(); + + URLClassLoader classLoader = new URLClassLoader( + new URL[]{outputDir.toUri().toURL()}, getClass().getClassLoader()); + + return new CompilationResult(procSuccess && compileSuccess, + diagnostics.getDiagnostics().toString() + compileDiagnostics.getDiagnostics().toString(), + classLoader); + } + } + + private static class CompilationResult { + final boolean success; + final String diagnostics; + final URLClassLoader classLoader; + + CompilationResult(boolean success, String diagnostics, URLClassLoader classLoader) { + this.success = success; + this.diagnostics = diagnostics; + this.classLoader = classLoader; + } + } + + private static class InMemorySource extends SimpleJavaFileObject { + private final String code; + + InMemorySource(String className, String code) { + super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return code; + } + } +} diff --git a/aesh-processor/src/test/java/org/aesh/processor/ProcessorTest.java b/aesh-processor/src/test/java/org/aesh/processor/ProcessorTest.java new file mode 100644 index 00000000..6f7dabfc --- /dev/null +++ b/aesh-processor/src/test/java/org/aesh/processor/ProcessorTest.java @@ -0,0 +1,582 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.processor; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import org.aesh.command.Command; +import org.aesh.command.impl.container.AeshCommandContainerBuilder; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.internal.ProcessedOption; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.metadata.CommandMetadataProvider; +import org.junit.Test; + +/** + * Tests that the annotation processor generates metadata that is structurally + * equivalent to what the reflection-based AeshCommandContainerBuilder produces. + */ +public class ProcessorTest { + + // --- Test: Simple command with @Option and @Argument --- + + private static final String SIMPLE_COMMAND_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "import org.aesh.command.option.Argument;\n" + + "\n" + + "@CommandDefinition(name = \"simple\", description = \"A simple test command\")\n" + + "public class SimpleCommand implements Command {\n" + + " @Option(shortName = 'v', description = \"Enable verbose output\")\n" + + " private boolean verbose;\n" + + "\n" + + " @Option(name = \"output\", description = \"Output file\", required = true)\n" + + " private String outputFile;\n" + + "\n" + + " @Argument(description = \"Source file\")\n" + + " private String source;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + public void testSimpleCommand() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.SimpleCommand", SIMPLE_COMMAND_SOURCE)); + assertTrue("Compilation should succeed: " + result.diagnostics, result.success); + + // Load both the command class and the generated metadata provider + Class commandClass = result.classLoader.loadClass("test.SimpleCommand"); + Class metadataClass = result.classLoader.loadClass("test.SimpleCommand_AeshMetadata"); + + assertEquivalence(commandClass, metadataClass); + } + + // --- Test: All option types --- + + private static final String ALL_OPTIONS_COMMAND_SOURCE = + "package test;\n" + + "\n" + + "import java.util.List;\n" + + "import java.util.Map;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Arguments;\n" + + "import org.aesh.command.option.Option;\n" + + "import org.aesh.command.option.OptionGroup;\n" + + "import org.aesh.command.option.OptionList;\n" + + "\n" + + "@CommandDefinition(name = \"allopts\", description = \"All option types\")\n" + + "public class AllOptionsCommand implements Command {\n" + + " @Option(shortName = 'n', description = \"Name\")\n" + + " private String name;\n" + + "\n" + + " @OptionList(shortName = 'i', description = \"Items\")\n" + + " private List items;\n" + + "\n" + + " @OptionGroup(shortName = 'p', description = \"Properties\")\n" + + " private Map properties;\n" + + "\n" + + " @Arguments(description = \"Files to process\")\n" + + " private List files;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + public void testAllOptionTypes() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.AllOptionsCommand", ALL_OPTIONS_COMMAND_SOURCE)); + assertTrue("Compilation should succeed: " + result.diagnostics, result.success); + + Class commandClass = result.classLoader.loadClass("test.AllOptionsCommand"); + Class metadataClass = result.classLoader.loadClass("test.AllOptionsCommand_AeshMetadata"); + + assertEquivalence(commandClass, metadataClass); + } + + // --- Test: Boolean option with negatable --- + + private static final String BOOLEAN_COMMAND_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "@CommandDefinition(name = \"boolcmd\", description = \"Boolean options test\")\n" + + "public class BooleanCommand implements Command {\n" + + " @Option(hasValue = false, description = \"Flag\")\n" + + " private boolean flag;\n" + + "\n" + + " @Option(hasValue = false, negatable = true, description = \"Negatable flag\")\n" + + " private Boolean negatableFlag;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + public void testBooleanOptions() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.BooleanCommand", BOOLEAN_COMMAND_SOURCE)); + assertTrue("Compilation should succeed: " + result.diagnostics, result.success); + + Class commandClass = result.classLoader.loadClass("test.BooleanCommand"); + Class metadataClass = result.classLoader.loadClass("test.BooleanCommand_AeshMetadata"); + + assertEquivalence(commandClass, metadataClass); + } + + // --- Test: Command with default values --- + + private static final String DEFAULT_VALUES_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "@CommandDefinition(name = \"defaults\", description = \"Default values test\")\n" + + "public class DefaultValuesCommand implements Command {\n" + + " @Option(defaultValue = {\"hello\", \"world\"}, description = \"Greeting\")\n" + + " private String greeting;\n" + + "\n" + + " @Option(defaultValue = \"42\", description = \"Count\")\n" + + " private int count;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + public void testDefaultValues() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.DefaultValuesCommand", DEFAULT_VALUES_SOURCE)); + assertTrue("Compilation should succeed: " + result.diagnostics, result.success); + + Class commandClass = result.classLoader.loadClass("test.DefaultValuesCommand"); + Class metadataClass = result.classLoader.loadClass("test.DefaultValuesCommand_AeshMetadata"); + + assertEquivalence(commandClass, metadataClass); + } + + // --- Test: Command with aliases and version --- + + private static final String ALIASED_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "@CommandDefinition(name = \"aliased\", description = \"Aliased command\", aliases = {\"al\", \"a\"}, " + + "version = \"1.0\", generateHelp = true)\n" + + "public class AliasedCommand implements Command {\n" + + " @Option(description = \"Option\")\n" + + " private String opt;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + public void testAliasedCommand() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.AliasedCommand", ALIASED_SOURCE)); + assertTrue("Compilation should succeed: " + result.diagnostics, result.success); + + Class commandClass = result.classLoader.loadClass("test.AliasedCommand"); + Class metadataClass = result.classLoader.loadClass("test.AliasedCommand_AeshMetadata"); + + assertEquivalence(commandClass, metadataClass); + } + + // --- Test: Class hierarchy with options in superclass --- + + private static final String BASE_COMMAND_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "public abstract class BaseCommand implements Command {\n" + + " @Option(description = \"Debug mode\", hasValue = false)\n" + + " private boolean debug;\n" + + "}\n"; + + private static final String CHILD_COMMAND_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "@CommandDefinition(name = \"child\", description = \"Child command\")\n" + + "public class ChildCommand extends BaseCommand {\n" + + " @Option(description = \"Name\")\n" + + " private String name;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + public void testClassHierarchy() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.BaseCommand", BASE_COMMAND_SOURCE), + new InMemorySource("test.ChildCommand", CHILD_COMMAND_SOURCE)); + assertTrue("Compilation should succeed: " + result.diagnostics, result.success); + + Class commandClass = result.classLoader.loadClass("test.ChildCommand"); + Class metadataClass = result.classLoader.loadClass("test.ChildCommand_AeshMetadata"); + + assertEquivalence(commandClass, metadataClass); + } + + // --- Test: Group command --- + + private static final String SUB_COMMAND1_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "@CommandDefinition(name = \"sub1\", description = \"Subcommand 1\")\n" + + "public class SubCommand1 implements Command {\n" + + " @Option(description = \"Value\")\n" + + " private String value;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + private static final String SUB_COMMAND2_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "\n" + + "@CommandDefinition(name = \"sub2\", description = \"Subcommand 2\")\n" + + "public class SubCommand2 implements Command {\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + private static final String GROUP_COMMAND_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.GroupCommandDefinition;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "import org.aesh.command.option.Option;\n" + + "\n" + + "@GroupCommandDefinition(name = \"group\", description = \"Group command\",\n" + + " groupCommands = {SubCommand1.class, SubCommand2.class})\n" + + "public class GroupTestCommand implements Command {\n" + + " @Option(description = \"Shared option\")\n" + + " private String shared;\n" + + "\n" + + " @Override\n" + + " public CommandResult execute(CommandInvocation commandInvocation) {\n" + + " return CommandResult.SUCCESS;\n" + + " }\n" + + "}\n"; + + @Test + public void testGroupCommand() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.SubCommand1", SUB_COMMAND1_SOURCE), + new InMemorySource("test.SubCommand2", SUB_COMMAND2_SOURCE), + new InMemorySource("test.GroupTestCommand", GROUP_COMMAND_SOURCE)); + assertTrue("Compilation should succeed: " + result.diagnostics, result.success); + + Class commandClass = result.classLoader.loadClass("test.GroupTestCommand"); + Class metadataClass = result.classLoader.loadClass("test.GroupTestCommand_AeshMetadata"); + + // Verify group command metadata + CommandMetadataProvider provider = (CommandMetadataProvider) metadataClass.newInstance(); + assertTrue("Should be a group command", provider.isGroupCommand()); + assertEquals("Should have 2 subcommands", 2, provider.groupCommandClasses().length); + + assertEquivalence(commandClass, metadataClass); + } + + // --- Test: Compile-time validation catches abstract class --- + + private static final String ABSTRACT_COMMAND_SOURCE = + "package test;\n" + + "\n" + + "import org.aesh.command.Command;\n" + + "import org.aesh.command.CommandDefinition;\n" + + "import org.aesh.command.CommandResult;\n" + + "import org.aesh.command.invocation.CommandInvocation;\n" + + "\n" + + "@CommandDefinition(name = \"bad\", description = \"Bad command\")\n" + + "public abstract class AbstractBadCommand implements Command {\n" + + "}\n"; + + @Test + public void testAbstractClassError() throws Exception { + CompilationResult result = compileWithProcessor( + new InMemorySource("test.AbstractBadCommand", ABSTRACT_COMMAND_SOURCE)); + // Should have compilation errors + assertTrue("Should report error for abstract command class", + result.diagnostics.toString().contains("must not be abstract")); + } + + // --- Equivalence assertion --- + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void assertEquivalence(Class commandClass, Class metadataClass) throws Exception { + // Build via reflection (existing path) + AeshCommandContainerBuilder reflectionBuilder = new AeshCommandContainerBuilder(); + ProcessedCommand reflectionPC = reflectionBuilder.create( + (Command) commandClass.newInstance()).getParser().getProcessedCommand(); + + // Build via generated provider + CommandMetadataProvider provider = (CommandMetadataProvider) metadataClass.newInstance(); + Command instance = (Command) commandClass.newInstance(); + ProcessedCommand generatedPC = provider.buildProcessedCommand(instance); + + // Assert structural equivalence + assertEquals("Command name", reflectionPC.name(), generatedPC.name()); + assertEquals("Command description", reflectionPC.description(), generatedPC.description()); + assertEquals("Aliases", reflectionPC.getAliases(), generatedPC.getAliases()); + assertEquals("Version", + reflectionPC.version() != null ? reflectionPC.version() : "", + generatedPC.version() != null ? generatedPC.version() : ""); + + // Compare options + List reflectionOpts = reflectionPC.getOptions(); + List generatedOpts = generatedPC.getOptions(); + assertEquals("Number of options", reflectionOpts.size(), generatedOpts.size()); + + for (int i = 0; i < reflectionOpts.size(); i++) { + ProcessedOption rOpt = reflectionOpts.get(i); + // Find matching option by name in generated (order may differ) + ProcessedOption gOpt = findOptionByName(generatedOpts, rOpt.name()); + assertNotNull("Generated should have option: " + rOpt.name(), gOpt); + + assertEquals("Option name", rOpt.name(), gOpt.name()); + assertEquals("Option shortName for " + rOpt.name(), rOpt.shortName(), gOpt.shortName()); + assertEquals("Option description for " + rOpt.name(), rOpt.description(), gOpt.description()); + assertEquals("Option type for " + rOpt.name(), rOpt.type(), gOpt.type()); + assertEquals("Option fieldName for " + rOpt.name(), rOpt.getFieldName(), gOpt.getFieldName()); + assertEquals("Option optionType for " + rOpt.name(), rOpt.getOptionType(), gOpt.getOptionType()); + assertEquals("Option required for " + rOpt.name(), rOpt.isRequired(), gOpt.isRequired()); + assertEquals("Option defaultValues for " + rOpt.name(), rOpt.getDefaultValues(), gOpt.getDefaultValues()); + assertEquals("Option negatable for " + rOpt.name(), rOpt.isNegatable(), gOpt.isNegatable()); + assertEquals("Option inherited for " + rOpt.name(), rOpt.isInherited(), gOpt.isInherited()); + } + + // Compare argument + if (reflectionPC.getArgument() != null) { + assertNotNull("Generated should have argument", generatedPC.getArgument()); + ProcessedOption rArg = reflectionPC.getArgument(); + ProcessedOption gArg = generatedPC.getArgument(); + assertEquals("Argument description", rArg.description(), gArg.description()); + assertEquals("Argument type", rArg.type(), gArg.type()); + assertEquals("Argument fieldName", rArg.getFieldName(), gArg.getFieldName()); + assertEquals("Argument optionType", rArg.getOptionType(), gArg.getOptionType()); + assertEquals("Argument required", rArg.isRequired(), gArg.isRequired()); + } + + // Compare arguments (plural) + if (reflectionPC.getArguments() != null) { + assertNotNull("Generated should have arguments", generatedPC.getArguments()); + ProcessedOption rArgs = reflectionPC.getArguments(); + ProcessedOption gArgs = generatedPC.getArguments(); + assertEquals("Arguments description", rArgs.description(), gArgs.description()); + assertEquals("Arguments type", rArgs.type(), gArgs.type()); + assertEquals("Arguments fieldName", rArgs.getFieldName(), gArgs.getFieldName()); + assertEquals("Arguments optionType", rArgs.getOptionType(), gArgs.getOptionType()); + } + } + + private ProcessedOption findOptionByName(List options, String name) { + for (ProcessedOption opt : options) { + if (opt.name().equals(name)) return opt; + } + return null; + } + + // --- In-memory compilation infrastructure --- + + private CompilationResult compileWithProcessor(InMemorySource... sources) throws IOException { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + + Path outputDir = Files.createTempDirectory("aesh-processor-test"); + + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) { + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, + Collections.singletonList(outputDir.toFile())); + + // Add aesh classes to the classpath + String classpath = System.getProperty("java.class.path"); + String[] cpEntries = classpath.split(File.pathSeparator); + List cpFiles = new java.util.ArrayList<>(); + for (String entry : cpEntries) { + cpFiles.add(new File(entry)); + } + fileManager.setLocation(StandardLocation.CLASS_PATH, cpFiles); + + JavaCompiler.CompilationTask task = compiler.getTask( + null, fileManager, diagnostics, + Arrays.asList("-proc:only", "-processor", AeshAnnotationProcessor.class.getName()), + null, Arrays.asList(sources)); + + boolean procSuccess = task.call(); + + // Now compile again with generated sources + original sources + // First, collect generated source files + List allSources = new java.util.ArrayList<>(Arrays.asList(sources)); + + // Find generated source files + collectGeneratedSources(outputDir, allSources); + + // Compile everything (no annotation processing this time) + DiagnosticCollector compileDiagnostics = new DiagnosticCollector<>(); + JavaCompiler.CompilationTask compileTask = compiler.getTask( + null, fileManager, compileDiagnostics, + Arrays.asList("-proc:none"), + null, allSources); + + boolean compileSuccess = compileTask.call(); + boolean success = procSuccess && compileSuccess; + + // Build classloader from output + URLClassLoader classLoader = new URLClassLoader( + new URL[]{outputDir.toUri().toURL()}, + getClass().getClassLoader()); + + String allDiags = diagnostics.getDiagnostics().toString() + " " + compileDiagnostics.getDiagnostics().toString(); + return new CompilationResult(success, allDiags, classLoader, outputDir); + } + } + + private void collectGeneratedSources(Path dir, List sources) throws IOException { + Files.walk(dir) + .filter(p -> p.toString().endsWith(".java")) + .forEach(p -> { + try { + String content = new String(Files.readAllBytes(p), StandardCharsets.UTF_8); + // Determine the class name from path + String relativePath = dir.relativize(p).toString(); + String className = relativePath.replace(File.separatorChar, '.') + .replace(".java", ""); + sources.add(new InMemorySource(className, content)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + private static class CompilationResult { + final boolean success; + final String diagnostics; + final URLClassLoader classLoader; + final Path outputDir; + + CompilationResult(boolean success, String diagnostics, URLClassLoader classLoader, Path outputDir) { + this.success = success; + this.diagnostics = diagnostics; + this.classLoader = classLoader; + this.outputDir = outputDir; + } + } + + private static class InMemorySource extends SimpleJavaFileObject { + private final String code; + + InMemorySource(String className, String code) { + super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), + Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return code; + } + } +} diff --git a/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java b/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java index 86bd80d5..98ccc9cc 100644 --- a/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java @@ -47,6 +47,8 @@ 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.metadata.MetadataProviderRegistry; import org.aesh.command.option.Argument; import org.aesh.command.option.Arguments; import org.aesh.command.option.Option; @@ -65,14 +67,55 @@ public class AeshCommandContainerBuilder implement @Override public CommandContainer create(Command command) throws CommandLineParserException { + CommandMetadataProvider provider = MetadataProviderRegistry.getProvider(command.getClass()); + if (provider != null) { + return buildFromProvider(provider, command); + } return doGenerateCommandLineParser(command); } @Override public CommandContainer create(Class command) throws CommandLineParserException { + CommandMetadataProvider provider = MetadataProviderRegistry.getProvider(command); + if (provider != null) { + return buildFromProvider(provider, provider.newInstance()); + } return doGenerateCommandLineParser(ReflectionUtil.newInstance(command)); } + private AeshCommandContainer buildFromProvider(CommandMetadataProvider provider, Command command) + throws CommandLineParserException { + ProcessedCommand, CI> processedCommand = provider.buildProcessedCommand(command); + + AeshCommandContainer container = new AeshCommandContainer<>( + CommandLineParserBuilder., CI>builder() + .processedCommand(processedCommand) + .create()); + + if (provider.isGroupCommand()) { + if (command instanceof GroupCommand) { + List> commands = ((GroupCommand) command).getCommands(); + if (commands != null) { + for (Command sub : commands) { + container.addChild(create(sub)); + } + } + List> parsedCommands = ((GroupCommand) command).getParsedCommands(); + if (parsedCommands != null) { + for (CommandContainer sub : parsedCommands) { + container.addChild(sub); + } + } + } else { + for (Class groupClazz : provider.groupCommandClasses()) { + container.addChild(create(groupClazz)); + } + } + } + + return container; + } + private AeshCommandContainer doGenerateCommandLineParser(Command commandObject) throws CommandLineParserException { Class> clazz = (Class>) commandObject.getClass(); CommandDefinition command = clazz.getAnnotation(CommandDefinition.class); diff --git a/aesh/src/main/java/org/aesh/command/metadata/CommandMetadataProvider.java b/aesh/src/main/java/org/aesh/command/metadata/CommandMetadataProvider.java new file mode 100644 index 00000000..4506ad6b --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/metadata/CommandMetadataProvider.java @@ -0,0 +1,68 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.command.metadata; + +import org.aesh.command.Command; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.parser.CommandLineParserException; + +/** + * Provides compile-time generated metadata for a command class, + * eliminating the need for runtime reflection to scan annotations. + *

+ * Implementations are generated by the aesh-processor annotation processor + * and discovered via {@link java.util.ServiceLoader}. + * + * @param the command type + * @author Aesh team + */ +public interface CommandMetadataProvider { + + /** + * @return the command class this provider handles + */ + Class commandType(); + + /** + * Create a new instance of the command without reflection. + * + * @return a new command instance + */ + C newInstance(); + + /** + * Build the processed command metadata using literal values + * extracted at compile time, avoiding annotation scanning at runtime. + * + * @param instance the command instance to attach to the metadata + * @return the fully built ProcessedCommand + * @throws CommandLineParserException if command metadata is invalid + */ + @SuppressWarnings("rawtypes") + ProcessedCommand buildProcessedCommand(C instance) throws CommandLineParserException; + + /** + * @return true if this command is a group command with subcommands + */ + boolean isGroupCommand(); + + /** + * @return the subcommand classes for a group command, empty array if not a group + */ + Class[] groupCommandClasses(); +} diff --git a/aesh/src/main/java/org/aesh/command/metadata/MetadataProviderRegistry.java b/aesh/src/main/java/org/aesh/command/metadata/MetadataProviderRegistry.java new file mode 100644 index 00000000..d176aff4 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/metadata/MetadataProviderRegistry.java @@ -0,0 +1,84 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag. All rights reserved. + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * 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 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.command.metadata; + +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.ConcurrentHashMap; + +import org.aesh.command.Command; + +/** + * Registry that discovers and caches {@link CommandMetadataProvider} implementations + * via {@link ServiceLoader}. Thread-safe with lazy initialization. + * + * @author Aesh team + */ +public final class MetadataProviderRegistry { + + private static volatile Map, CommandMetadataProvider> providers; + + private MetadataProviderRegistry() { + } + + /** + * Look up a provider for the given command class. + * + * @param commandClass the command class to look up + * @param the command type + * @return the provider, or null if no generated provider exists + */ + @SuppressWarnings("unchecked") + public static CommandMetadataProvider getProvider(Class commandClass) { + return (CommandMetadataProvider) getProviders().get(commandClass); + } + + private static Map, CommandMetadataProvider> getProviders() { + Map, CommandMetadataProvider> result = providers; + if (result == null) { + synchronized (MetadataProviderRegistry.class) { + result = providers; + if (result == null) { + result = loadProviders(); + providers = result; + } + } + } + return result; + } + + @SuppressWarnings("rawtypes") + private static Map, CommandMetadataProvider> loadProviders() { + Map, CommandMetadataProvider> map = new ConcurrentHashMap<>(); + ServiceLoader loader = ServiceLoader.load(CommandMetadataProvider.class); + for (CommandMetadataProvider provider : loader) { + map.put(provider.commandType(), provider); + } + return map; + } + + /** + * Reset the registry, forcing re-discovery on next access. + * Package-private for testing. + */ + static void reset() { + synchronized (MetadataProviderRegistry.class) { + providers = null; + } + } +} diff --git a/pom.xml b/pom.xml index a7da1212..f47cd140 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ aesh + aesh-processor