Generator.java
package net.morimekta.terminal.args.impl;
import net.morimekta.strings.NamingUtil;
import net.morimekta.terminal.args.ArgNameFormat;
import net.morimekta.terminal.args.ArgParser;
import net.morimekta.terminal.args.Flag;
import net.morimekta.terminal.args.Option;
import net.morimekta.terminal.args.Property;
import net.morimekta.terminal.args.ValueParser;
import net.morimekta.terminal.args.annotations.ArgIgnore;
import net.morimekta.terminal.args.annotations.ArgIsHidden;
import net.morimekta.terminal.args.annotations.ArgIsRepeated;
import net.morimekta.terminal.args.annotations.ArgIsRequired;
import net.morimekta.terminal.args.annotations.ArgKeyParser;
import net.morimekta.terminal.args.annotations.ArgNaming;
import net.morimekta.terminal.args.annotations.ArgOptions;
import net.morimekta.terminal.args.reference.AdderInstanceReference;
import net.morimekta.terminal.args.reference.AdderMethodReference;
import net.morimekta.terminal.args.reference.AdderReference;
import net.morimekta.terminal.args.reference.ChainedAdderReference;
import net.morimekta.terminal.args.reference.ChainedPutterReference;
import net.morimekta.terminal.args.reference.FieldReference;
import net.morimekta.terminal.args.reference.MethodReference;
import net.morimekta.terminal.args.reference.PutterInstanceReference;
import net.morimekta.terminal.args.reference.PutterMethodReference;
import net.morimekta.terminal.args.reference.PutterReference;
import net.morimekta.terminal.args.reference.Reference;
import net.morimekta.terminal.args.reference.SettableFieldReference;
import net.morimekta.terminal.args.reference.SettableMethodReference;
import net.morimekta.terminal.args.reference.SettableReference;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.lang.System.Logger.Level.TRACE;
import static net.morimekta.strings.NamingUtil.Format.CAMEL;
import static net.morimekta.strings.NamingUtil.Format.PASCAL;
import static net.morimekta.terminal.args.impl.GeneratorUtil.createCollectionSupplier;
import static net.morimekta.terminal.args.impl.GeneratorUtil.createMapSupplier;
import static net.morimekta.terminal.args.impl.GeneratorUtil.findField;
import static net.morimekta.terminal.args.impl.GeneratorUtil.findMethod;
import static net.morimekta.terminal.args.impl.GeneratorUtil.getAnnotation;
import static net.morimekta.terminal.args.impl.GeneratorUtil.getKeyParser;
import static net.morimekta.terminal.args.impl.GeneratorUtil.getShortChar;
import static net.morimekta.terminal.args.impl.GeneratorUtil.getShortName;
import static net.morimekta.terminal.args.impl.GeneratorUtil.getUsage;
import static net.morimekta.terminal.args.impl.GeneratorUtil.getValueParser;
import static net.morimekta.terminal.args.impl.GeneratorUtil.isDeclared;
import static net.morimekta.terminal.args.impl.GeneratorUtil.isPublic;
import static net.morimekta.terminal.args.impl.GeneratorUtil.isStatic;
public final class Generator {
private static final System.Logger LOGGER = System.getLogger(Generator.class.getName());
private Generator() {}
public static ArgParser.Builder generateArgs(ArgParser.Builder args,
Object config,
String namePrefix,
ArgNameFormat defaultNameFormat) {
generateArgsInternal(args, config, namePrefix, defaultNameFormat);
return args;
}
private static boolean generateArgsInternal(
ArgParser.Builder args,
Object config,
String namePrefix,
ArgNameFormat parentNameFormat) {
// when checking fields, if we need any of these methods to access, then ignore,
// as the field should already have been handled.
var handledMethods = new HashSet<Method>();
var handledFields = new HashSet<Field>();
var acceptedNames = new TreeMap<String, Reference>();
var argumentNamingAnnotation = getAnnotation(config.getClass(), ArgNaming.class);
var nameFormat = argumentNamingAnnotation != null
? argumentNamingAnnotation.value()
: parentNameFormat;
for (Method method : Stream.of(config.getClass().getMethods())
.filter(m -> isPublic(m) && !isStatic(m))
.sorted(Generator::compareMethods)
.collect(Collectors.toList())) {
var declaredPackage = method.getDeclaringClass().getPackage().getName();
if (declaredPackage.startsWith("java.") ||
declaredPackage.startsWith("javax.") ||
declaredPackage.startsWith("kotlin.") ||
handledMethods.contains(method)) {
continue;
}
handledMethods.add(method);
if (method.isAnnotationPresent(ArgIgnore.class)) {
LOGGER.log(TRACE, "Ignored method " + method.getName() + "().");
continue;
}
Matcher setterMatcher = SETTER.matcher(method.getName());
Matcher getterMatcher = GETTER.matcher(method.getName());
var isSetter = setterMatcher.matches();
var isGetter = getterMatcher.matches();
var methodNamePart = (!isSetter && !isGetter)
? method.getName()
: (isSetter ? setterMatcher : getterMatcher).group("name");
// Stupid thing kotlin does with annotations, especially if this is
// a getter or setter for a field (automatic get/set in kotlin)...
var field = findField(method.getDeclaringClass(), NamingUtil.format(methodNamePart, CAMEL));
field.ifPresent(handledFields::add);
// - The static getName$annotations -- kotlin thing.
// - The associated field.
// - The method itself.
AccessibleObject annotationRef = findMethod(method.getDeclaringClass(),
"get" + methodNamePart + "$annotations")
.map(m -> (AccessibleObject) m)
.or(() -> Optional.ofNullable((AccessibleObject) field.orElse(null)))
.orElse(method);
if (annotationRef.isAnnotationPresent(ArgIgnore.class)) {
// Ignored based on associated annotation holder.
LOGGER.log(TRACE, "Ignored method " + method.getName() + "(associated annotation).");
continue;
}
var declared = isDeclared(method) || isDeclared(annotationRef);
var argumentName = "";
if (method.isAnnotationPresent(ArgOptions.class)) {
argumentName = method.getAnnotation(ArgOptions.class).name();
}
if (argumentName.isEmpty() && annotationRef.isAnnotationPresent(ArgOptions.class)) {
argumentName = annotationRef.getAnnotation(ArgOptions.class).name();
}
if (argumentName.isEmpty()) {
argumentName = nameFormat.format(methodNamePart);
}
var name = "--" + parentNameFormat.join(namePrefix, argumentName);
var isAdder = false;
var isPutter = false;
if (isSetter) {
switch (method.getParameterCount()) {
case 0:
if (declared) {
throw new IllegalArgumentException("No parameters on setter: " + method.getName() + "().");
}
LOGGER.log(TRACE, "No parameters on setter: " + method.getName() + "().");
continue;
case 1:
if (method.getName().startsWith("add")) {
isAdder = true;
}
break;
case 2:
isPutter = true;
break;
default:
if (declared) {
throw new IllegalArgumentException("Too many parameters on setter: " + method.getName() + "().");
}
LOGGER.log(TRACE, "Too many parameters on setter: " + method.getName() + "().");
continue;
}
} else if (isGetter) {
if (method.getParameterCount() > 0) {
if (declared) {
throw new IllegalArgumentException("Parameters on getter: " + method.getName() + "().");
}
LOGGER.log(TRACE, "Parameters on getter: " + method.getName() + "().");
continue;
}
} else {
if (!declared) {
LOGGER.log(TRACE, "Ignoring method " + method.getName() + "(), unrecognized name pattern.");
continue;
}
switch (method.getParameterCount()) {
case 0:
isGetter = true;
break;
case 1:
isSetter = true;
break;
case 2:
isPutter = true;
isSetter = true;
break;
default:
throw new IllegalArgumentException(
"Unknown param count for arguments: " + method.getName() + " = " + method.getParameterCount());
}
}
Optional<Method> setter = isSetter ? Optional.of(method) : Optional.empty();
Optional<Method> getter = isGetter ? Optional.of(method) : Optional.empty();
if (getter.isEmpty()) {
getter = findMethod(method.getDeclaringClass(), "get" + methodNamePart)
.filter(GeneratorUtil::isPublic)
.or(() -> findMethod(method.getDeclaringClass(), "is" + methodNamePart))
.filter(GeneratorUtil::isPublic);
getter.ifPresent(handledMethods::add);
}
if (acceptedNames.containsKey(name)) {
// conflicting name
throw new IllegalArgumentException("Conflicting name: " + method.getName() + " for " + name);
}
if (isPutter) {
LOGGER.log(TRACE, "Putter method: " + method.getName() + "(): " + name);
var reference = new PutterMethodReference(
config, getter.orElse(null), setter.orElse(null), annotationRef);
if (addPutter(name, reference, args)) {
acceptedNames.put(name, reference);
}
} else if (isAdder) {
LOGGER.log(TRACE, "Adder method: " + method.getName() + "(): " + name);
var reference = new AdderMethodReference(
config, getter.orElse(null), setter.orElse(null), annotationRef);
if (addAdder(name, reference, args)) {
acceptedNames.put(name, reference);
}
} else if (setter.isEmpty()) {
LOGGER.log(TRACE, "Getter method: " + method.getName() + "(): " + name);
var reference = new MethodReference(config, method, annotationRef);
if (addOnImmutable(name, reference, args, nameFormat)) {
acceptedNames.put(name, reference);
}
} else {
LOGGER.log(TRACE, "Setter method: " + method.getName() + "(): " + name);
var reference = new SettableMethodReference(config, getter.orElse(null), method, annotationRef);
if (addSettable(name, reference, args, nameFormat)) {
acceptedNames.put(name, reference);
}
}
}
var fieldArguments = new HashSet<String>();
for (Field field : config.getClass().getFields()) {
if ((field.getModifiers() & Modifier.STATIC) == Modifier.STATIC ||
(field.getModifiers() & Modifier.PUBLIC) != Modifier.PUBLIC ||
handledFields.contains(field)) {
// We are not interested in static or non-public fields.
continue;
}
if (field.isAnnotationPresent(ArgIgnore.class)) {
LOGGER.log(TRACE, "Ignored field " + field.getName());
continue;
}
var declared = isDeclared(field);
var pascalName = NamingUtil.format(field.getName(), PASCAL);
Optional<Method> getter =
findMethod(field.getDeclaringClass(), "get" + pascalName)
.or(() -> findMethod(field.getDeclaringClass(), "is" + pascalName))
.filter(GeneratorUtil::isPublic);
getter.ifPresent(handledMethods::add);
Optional<Method> setter = findMethod(field.getDeclaringClass(), "set" + pascalName, field.getType())
.filter(GeneratorUtil::isPublic);
setter.ifPresent(handledMethods::add);
var argumentName = "";
if (field.isAnnotationPresent(ArgOptions.class)) {
argumentName = field.getAnnotation(ArgOptions.class).name();
}
if (argumentName.isEmpty()) {
argumentName = nameFormat.format(field.getName());
}
if (argumentName.isEmpty()) {
if (declared) {
throw new IllegalArgumentException("Ignored field " + field.getName() + ", unable to generate name.");
}
LOGGER.log(TRACE, "Ignored field " + field.getName() + ", unable to generate name.");
continue;
}
var name = "--" + parentNameFormat.join(namePrefix, argumentName);
if (acceptedNames.containsKey(name)) {
if (declared || fieldArguments.contains(name)) {
throw new IllegalArgumentException("Name conflict for field " + field.getName() + ": '" + name + "'.");
}
LOGGER.log(TRACE, "Ignored field " + field.getName() + ", already handled by method.");
continue;
}
if ((field.getModifiers() & Modifier.FINAL) == Modifier.FINAL) {
LOGGER.log(TRACE, "Immutable field " + field.getName() + " -> '" + name + "'.");
var reference = new FieldReference(config, field, field);
if (addOnImmutable(name, reference, args, nameFormat)) {
acceptedNames.put(name, reference);
fieldArguments.add(name);
}
} else {
LOGGER.log(TRACE, "Settable field " + field.getName() + " -> '" + name + "'.");
var reference = new SettableFieldReference(config, field, field);
if (addSettable(name, reference, args, nameFormat)) {
acceptedNames.put(name, reference);
fieldArguments.add(name);
}
}
}
return acceptedNames.size() > 0;
}
public static boolean addSettable(
String name,
SettableReference ref,
ArgParser.Builder args,
ArgNameFormat defaultNameFormat) {
var type = ref.getType();
var isMap = Map.class.isAssignableFrom(type);
// If this is a map type settable, it must be parsed as a property.
// otherwise we may use a configured value parser as the parser for
// the whole map, and not just for the map item value.
// TODO: Allow a value parser, and check if the type of the parser
// is of map type, and use that to determine if it can be used
// as option or map property.
var valueParser = isMap ? null : getValueParser(type, ref);
if (valueParser != null) {
return addOption(name, ref, valueParser, args);
} else if (Boolean.class.isAssignableFrom(type) ||
Boolean.TYPE.isAssignableFrom(type)) {
return addFlag(name, ref, args);
} else if (isMap) {
// putting values in map.
var generic = ref.getGenericType();
Class<?> keyType;
Class<?> valueType;
if (generic instanceof ParameterizedType) {
var pt = (ParameterizedType) generic;
if (pt.getActualTypeArguments()[0] instanceof Class &&
pt.getActualTypeArguments()[1] instanceof Class) {
keyType = (Class<?>) pt.getActualTypeArguments()[0];
valueType = (Class<?>) pt.getActualTypeArguments()[1];
} else {
LOGGER.log(TRACE, " - Missing map generics for " + name + ".");
return false;
}
} else {
if (isDeclared(ref)) {
throw new IllegalArgumentException("Missing map generics for " + name + ".");
}
LOGGER.log(TRACE, " - Missing map generics for " + name + ".");
return false;
}
var mkMap = createMapSupplier(ref.getType());
return addPutter(name, new ChainedPutterReference(ref, keyType, valueType, mkMap), args);
} else if (Collection.class.isAssignableFrom(type)) {
// adding values to collection.
var generic = ref.getGenericType();
Class<?> itemType;
if (generic instanceof ParameterizedType) {
var pt = (ParameterizedType) generic;
if (pt.getActualTypeArguments()[0] instanceof Class) {
itemType = (Class<?>) pt.getActualTypeArguments()[0];
} else {
LOGGER.log(TRACE, " - Missing collection generics for " + name + ".");
return false;
}
} else {
if (isDeclared(ref)) {
throw new IllegalArgumentException("Missing collection generics for " + name + ".");
}
LOGGER.log(TRACE, " - Missing collection generics for " + name + ".");
return false;
}
var mkCollection = createCollectionSupplier(ref.getType());
return addAdder(name, new ChainedAdderReference(ref, itemType, mkCollection), args);
} else {
// possibly chained object arguments. This must already be instantiated.
Object object = ref.get();
if (object == null) {
LOGGER.log(TRACE, " - Ignoring chained arguments for " + name + ", no object.");
return false;
}
return addChained(name, object, args, defaultNameFormat);
}
}
public static boolean addOnImmutable(
String name,
Reference ref,
ArgParser.Builder args,
ArgNameFormat defaultNameFormat) {
// final, only for certain cases, in all instances the object must already exist:
// - for chained object arguments.
// - for putting values in map.
// - for adding values to collection.
Object object = ref.get();
if (object == null) {
if (isDeclared(ref)) {
throw new IllegalArgumentException("Null instance on declared argument on " + ref.getName() + ".");
}
LOGGER.log(TRACE, " - Ignoring chained arguments for " + name + " (immutable), no object.");
return false;
}
// possibly chained object arguments.
if (object instanceof Map) {
var generic = ref.getGenericType();
if (!(generic instanceof ParameterizedType)) {
if (isDeclared(ref)) {
throw new IllegalArgumentException("Unable to determine map type of " + ref.getName() + ".");
}
LOGGER.log(TRACE, " - Unable to determine map type of " + ref.getName() + ".");
return false;
}
var parameterizedType = (ParameterizedType) generic;
if (parameterizedType.getActualTypeArguments().length != 2 ||
!(parameterizedType.getActualTypeArguments()[0] instanceof Class) ||
!(parameterizedType.getActualTypeArguments()[1] instanceof Class)) {
if (isDeclared(ref)) {
throw new IllegalArgumentException("Unable to determine map types of " + ref.getName() + ".");
}
LOGGER.log(TRACE, " - Unable to determine map types of " + ref.getName() + ".");
return false;
}
var keyType = (Class<?>) parameterizedType.getActualTypeArguments()[0];
var valueType = (Class<?>) parameterizedType.getActualTypeArguments()[1];
@SuppressWarnings("unchecked")
var putter = new PutterInstanceReference(
ref.getName(),
keyType,
valueType,
(Map<Object, Object>) object,
ref);
return addPutter(name, putter, args);
} else if (object instanceof Collection) {
var generic = ref.getGenericType();
if (!(generic instanceof ParameterizedType)) {
if (isDeclared(ref)) {
throw new IllegalArgumentException("Unable to determine collection type of " + ref.getName() + ".");
}
LOGGER.log(TRACE, " - Unable to determine collection type of " + ref.getName() + ".");
return false;
}
var parameterizedType = (ParameterizedType) generic;
if (parameterizedType.getActualTypeArguments().length != 1 ||
!(parameterizedType.getActualTypeArguments()[0] instanceof Class)) {
if (isDeclared(ref)) {
throw new IllegalArgumentException("Unable to determine collection type of " + ref.getName() + ".");
}
LOGGER.log(TRACE, " - Unable to determine collection type of " + ref.getName() + ".");
return false;
}
var itemType = (Class<?>) parameterizedType.getActualTypeArguments()[0];
@SuppressWarnings("unchecked")
var adder = new AdderInstanceReference(
ref.getName(),
itemType,
(Collection<Object>) object,
ref);
return addAdder(name, adder, args);
} else {
return addChained(name, object, args, defaultNameFormat);
}
}
public static boolean addChained(
String name,
Object instance,
ArgParser.Builder args,
ArgNameFormat defaultNameFormat) {
String subPrefix = name.substring(2); // skip the "--" part.
return generateArgsInternal(args, instance, subPrefix, defaultNameFormat);
}
public static boolean addAdder(String name, AdderReference ref, ArgParser.Builder args) {
var itemParser = getValueParser(ref.getItemType(), ref);
if (itemParser == null) {
if (isDeclared(ref)) {
throw new IllegalArgumentException(
"Unknown value parser for item type " + ref.getItemType().getSimpleName() +
" for " + name + ".");
}
LOGGER.log(TRACE, "Ignoring adder " + name + ", unknown item type.");
return false;
}
var shortArgs = getShortName(ref);
var usage = getUsage(ref);
var builder = shortArgs.isEmpty()
? Option.optionLong(name, usage, itemParser.andApply(ref::add))
: Option.option(name, shortArgs, usage, itemParser.andApply(ref::add));
builder = builder.repeated();
if (ref.isAnnotationPresent(ArgIsHidden.class)) {
builder = builder.hidden();
}
if (ref.isAnnotationPresent(ArgIsRequired.class)) {
builder = builder.required();
}
args.add(builder);
return true;
}
public static boolean addPutter(String name, PutterReference ref, ArgParser.Builder args) {
var keyParser = getKeyParser(ref.getKeyType(), ref);
var valueParser = getValueParser(ref.getValueType(), ref);
if (keyParser == null || valueParser == null) {
if (isDeclared(ref)) {
throw new IllegalArgumentException(
"Unknown value parser for key " + ref.getKeyType().getSimpleName() +
" or value " + ref.getValueType().getSimpleName() +
" for " + name + ".");
}
LOGGER.log(TRACE, "Ignoring putter " + name + ", unknown item type.");
return false;
}
var putter = new Property.Putter() {
@Override
public void put(String key, String value) {
var parsedKey = keyParser.parse(key);
var parsedValue = valueParser.parse(value);
ref.put(parsedKey, parsedValue);
}
};
var shortArgs = getShortChar(ref);
var usage = getUsage(ref);
var builder = shortArgs == '\0' ?
Property.propertyLong(name, usage, putter) :
Property.property(name, shortArgs, usage, putter);
if (ref.isAnnotationPresent(ArgIsHidden.class)) {
builder = builder.hidden();
}
if (ref.isAnnotationPresent(ArgIsRequired.class)) {
throw new IllegalArgumentException("Properties cannot be required: " + ref.getName());
}
args.add(builder);
return true;
}
public static boolean addFlag(String name, SettableReference ref, ArgParser.Builder args) {
var shortArgs = getShortName(ref);
var usage = getUsage(ref);
var builder = shortArgs.isEmpty() ?
Flag.flagLong(name, usage, ref::set) :
Flag.flag(name, shortArgs, usage, ref::set);
if (ref.isAnnotationPresent(ArgIsHidden.class)) {
builder = builder.hidden();
}
if (ref.isAnnotationPresent(ArgIsRepeated.class)) {
builder = builder.repeated();
}
if (ref.isAnnotationPresent(ArgIsRequired.class)) {
throw new IllegalArgumentException("Flag cannot be required: " + ref.getName());
}
if (ref.isAnnotationPresent(ArgKeyParser.class)) {
throw new IllegalArgumentException("Invalid key parser override on flag: " + ref.getName());
}
args.add(builder);
return true;
}
public static boolean addOption(String name,
SettableReference ref,
ValueParser<Object> parser,
ArgParser.Builder args) {
var shortArgs = getShortName(ref);
var usage = getUsage(ref);
var builder = shortArgs.isEmpty() ?
Option.optionLong(name, usage, parser.andApply(ref::set)) :
Option.option(name, shortArgs, usage, parser.andApply(ref::set));
if (ref.isAnnotationPresent(ArgIsRepeated.class)) {
builder = builder.repeated();
}
if (ref.isAnnotationPresent(ArgIsRequired.class)) {
builder = builder.required();
}
if (ref.isAnnotationPresent(ArgIsHidden.class)) {
builder = builder.hidden();
}
if (ref.isAnnotationPresent(ArgKeyParser.class)) {
throw new IllegalArgumentException("Invalid key parser override on option: " + ref.getName());
}
args.add(builder);
return true;
}
private static boolean isGetter(Method method) {
return GETTER.matcher(method.getName()).matches();
}
private static boolean isSetter(Method method) {
return SETTER.matcher(method.getName()).matches();
}
private static int compareMethods(Method a, Method b) {
// Methods should be checked in the order:
// - setters
// - others
// - getters
// this way we should avoid setter -> getter collisions etc.
var aSetter = isSetter(a);
var bSetter = isSetter(b);
var aGetter = isGetter(a);
var bGetter = isGetter(b);
if (aSetter == bSetter &&
aGetter == bGetter) {
// compare name within the group to be consistent.
return a.getName().compareTo(b.getName());
} else if (aSetter) {
return -1;
} else if (bSetter) {
return 1;
} else if (aGetter) {
return 1;
} else {
// if bGetter, only option left.
return -1;
}
}
private static final Pattern SETTER = Pattern.compile(
"^(?:set|addTo|add|putIn|put|with)(?<name>[A-Z0-9][a-zA-Z0-9]*)$");
private static final Pattern GETTER = Pattern.compile(
"^(?:get|is)(?<name>[A-Z0-9][a-zA-Z0-9]*)$");
}