From bf13fcc8f02fc636a035d458e10c2926eafad9af Mon Sep 17 00:00:00 2001 From: Rob Vesse Date: Mon, 15 Apr 2019 11:20:46 +0100 Subject: [PATCH] Stub class for positional argument metadata (#91) - Adds `@PositionalArgument` annotation - Adds new `ArgumentMetadata` class - MetadataLoader collects positional arguments --- .../annotations/PositionalArgument.java | 97 ++++++ .../airline/model/ArgumentMetadata.java | 299 ++++++++++++++++++ .../airline/model/ArgumentsMetadata.java | 4 +- .../rvesse/airline/model/CommandMetadata.java | 8 + .../rvesse/airline/model/MetadataLoader.java | 135 +++++++- .../rvesse/airline/model/OptionMetadata.java | 5 +- 6 files changed, 526 insertions(+), 22 deletions(-) create mode 100644 airline-core/src/main/java/com/github/rvesse/airline/annotations/PositionalArgument.java create mode 100644 airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentMetadata.java diff --git a/airline-core/src/main/java/com/github/rvesse/airline/annotations/PositionalArgument.java b/airline-core/src/main/java/com/github/rvesse/airline/annotations/PositionalArgument.java new file mode 100644 index 000000000..2bbb56812 --- /dev/null +++ b/airline-core/src/main/java/com/github/rvesse/airline/annotations/PositionalArgument.java @@ -0,0 +1,97 @@ +/** + * Copyright (C) 2010-16 the original author or authors. + * + * 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 com.github.rvesse.airline.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import com.github.rvesse.airline.model.ArgumentMetadata; +import com.github.rvesse.airline.types.DefaultTypeConverterProvider; +import com.github.rvesse.airline.types.TypeConverterProvider; + +import static java.lang.annotation.ElementType.FIELD; + +import java.lang.annotation.Documented; + +/** + * Annotation that marks a field as being populated from a positional argument + * + */ +@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) +@Target({ FIELD }) +@Documented +public @interface PositionalArgument { + /** + * Name of the argument + * + * @return Name of the argument + */ + String title() default ""; + + /** + * A description of the argument + * + * @return Description + */ + String description() default ""; + + /** + * The positional index (one-based) for the argument + *

+ * So {@code 1} represents the first argument, {@code 3} the third argument + * and so forth + *

+ * + * @return + */ + int position(); + + /** + * If true this parameter can override parameters of the same index (set via + * the {@link PositionalArgument#position()} property) declared by parent classes assuming + * the argument definitions are compatible. + *

+ * See + * {@link ArgumentMetadata#override(ArgumentMetadata, ArgumentMetadata)} + * for legal overrides + *

+ *

+ * Note that where the child argument definition is an exact duplicate of the + * parent then overriding is implicitly permitted + *

+ * @return True if an override, false otherwise + */ + boolean override() default false; + + /** + * If true this parameter cannot be overridden by parameters of the same + * name declared in child classes regardless of whether the child class + * declares the {@link #override()} property to be true + * + * @return True if sealed, false otherwise + */ + boolean sealed() default false; + + /** + * Sets an alternative type converter provider for the argument. This allows + * the type converter for argument to be customised appropriately. By + * default this will defer to using the type converter provided in the + * parser configuration. + * + * @return Type converter provider + */ + Class typeConverterProvider() default DefaultTypeConverterProvider.class; +} diff --git a/airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentMetadata.java b/airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentMetadata.java new file mode 100644 index 000000000..aa9b8ee50 --- /dev/null +++ b/airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentMetadata.java @@ -0,0 +1,299 @@ +/** + * Copyright (C) 2010-16 the original author or authors. + * + * 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 com.github.rvesse.airline.model; + +import com.github.rvesse.airline.Accessor; +import com.github.rvesse.airline.restrictions.ArgumentsRestriction; +import com.github.rvesse.airline.types.DefaultTypeConverterProvider; +import com.github.rvesse.airline.types.TypeConverterProvider; +import com.github.rvesse.airline.utils.AirlineUtils; +import com.github.rvesse.airline.utils.predicates.restrictions.IsRequiredArgumentFinder; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.commons.collections4.IterableUtils; +import org.apache.commons.collections4.SetUtils; +import org.apache.commons.lang3.StringUtils; + +public class ArgumentMetadata { + private final int position; + private final String title, description; + private final boolean sealed, overrides; + private Set accessors; + private final List restrictions; + private final TypeConverterProvider provider; + + /** + * Creates new argument metadata + * + * @param position + * Zero based position index + * @param title + * Title + * @param description + * Description + * @param restrictions + * Restrictions + * @param typeConverterProvider + * Type converter provider + * @param path + * Field to modify + */ + //@formatter:off + public ArgumentMetadata(int position, String title, + String description, + boolean sealed, boolean overrides, + Iterable restrictions, + TypeConverterProvider typeConverterProvider, + Iterable path) { + //@formatter:on + if (position < 0) + throw new IllegalArgumentException("Position must be >= 0"); + if (title == null) + throw new NullPointerException("title cannot be null"); + if (path == null) + throw new NullPointerException("path cannot be null"); + if (!path.iterator().hasNext()) + throw new IllegalArgumentException("path cannot be empty"); + + this.position = position; + this.title = title; + this.description = description; + this.overrides = overrides; + this.sealed = sealed; + this.restrictions = restrictions != null ? AirlineUtils.unmodifiableListCopy(restrictions) + : Collections. emptyList(); + this.provider = typeConverterProvider != null ? typeConverterProvider : new DefaultTypeConverterProvider(); + this.accessors = SetUtils.unmodifiableSet(Collections.singleton(new Accessor(path))); + } + + public ArgumentMetadata(Iterable arguments) { + if (arguments == null) + throw new NullPointerException("arguments cannot be null"); + if (!arguments.iterator().hasNext()) + throw new IllegalArgumentException("arguments cannot be empty"); + + ArgumentMetadata first = arguments.iterator().next(); + + this.sealed = first.sealed; + this.overrides = first.overrides; + this.position = first.position; + this.title = first.title; + this.description = first.description; + this.restrictions = first.restrictions; + this.provider = first.provider; + + Set accessors = new HashSet<>(); + for (ArgumentMetadata other : arguments) { + if (!first.equals(other)) + throw new IllegalArgumentException( + String.format("Conflicting arguments definitions: %s, %s", first, other)); + + accessors.addAll(other.getAccessors()); + } + this.accessors = SetUtils.unmodifiableSet(accessors); + } + + /** + * Gets the zero based position index for the argument + * + * @return Position + */ + public int getZeroBasedPosition() { + return this.position; + } + + /** + * Gets the one based position index for the argument + * + * @return Position + */ + public int getOneBasedPosition() { + return this.position + 1; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public boolean isOverride() { + return overrides; + } + + public boolean isSealed() { + return sealed; + } + + public boolean isRequired() { + return IterableUtils.matchesAny(this.restrictions, new IsRequiredArgumentFinder()); + } + + public Set getAccessors() { + return accessors; + } + + public boolean isMultiValued() { + return accessors.iterator().next().isMultiValued(); + } + + public Class getJavaType() { + return accessors.iterator().next().getJavaType(); + } + + public List getRestrictions() { + return this.restrictions; + } + + public TypeConverterProvider getTypeConverterProvider() { + return this.provider; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + ArgumentMetadata that = (ArgumentMetadata) o; + + if (this.position != that.position) + return false; + if (!StringUtils.equals(this.description, that.description)) { + return false; + } + if (!StringUtils.equals(this.title, that.title)) { + return false; + } + + return true; + } + + @Override + public int hashCode() { + int result = title.hashCode(); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = result * this.position; + return result; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("ArgumentsMetadata"); + sb.append("{position=").append(this.position).append('\''); + sb.append(", title='").append(title).append('\''); + sb.append(", description='").append(description).append('\''); + sb.append(", accessors=").append(accessors); + sb.append('}'); + return sb.toString(); + } + + /** + * Tries to merge the argument metadata together such that the child + * metadata takes precedence. Not all arguments can be successfully + * overridden and an error may be thrown in cases where merging is not + * possible + *

+ * The following pieces of metadata may be overridden: + *

+ *
    + *
  • Title
  • + *
  • Description
  • + *
+ * + * @param parent + * Parent + * @param child + * Child + * @return Merged metadata + */ + public static ArgumentMetadata override(ArgumentMetadata parent, ArgumentMetadata child) { + // Cannot change position + if (parent.position != child.position) + throw new IllegalArgumentException( + String.format("Cannot change argument position when overriding positional argument %d (%s)", + parent.position, parent.title)); + + // Also cannot change the type of the argument unless the change is a + // narrowing conversion + Class parentType = parent.getJavaType(); + Class childType = child.getJavaType(); + if (!parentType.equals(childType)) { + if (!parentType.isAssignableFrom(childType)) { + if (childType.isAssignableFrom(parentType)) { + // A widening conversion exists but this is illegal however + // we can give a slightly more informative error in this + // case + throw new IllegalArgumentException(String.format( + "Cannot change the Java type from %s to %s when overriding positional argument %d (%s) as this is a widening type change - only narrowing type changes are permitted", + parentType, childType, parent.position, parent.title)); + } else { + // No conversion exists + throw new IllegalArgumentException(String.format( + "Cannot change the Java type from %s to %s when overriding positional argument %d (%s) - only narrowing type changes where a valid cast exists are permitted", + parentType, childType, parent.position, parent.title)); + } + } + } + + // Check for duplicates + boolean isDuplicate = parent == child || parent.equals(child); + + // Parent must not state it is sealed UNLESS it is a duplicate which can + // happen when using @Inject to inject options via delegates + if (parent.sealed && !isDuplicate) + throw new IllegalArgumentException( + String.format("Cannot override positional argument %d (%s) as parent argument declares it to be sealed", parent.position, parent.title)); + + // Child must explicitly state that it overrides otherwise we cannot + // override UNLESS it is the case that this is a duplicate which + // can happen when using @Inject to inject options via delegates + if (!child.overrides && !isDuplicate) + throw new IllegalArgumentException( + String.format("Cannot override positional argument %d (%s) unless child argument sets overrides to true", parent.position, parent.title)); + + ArgumentMetadata merged; + //@formatter:off + merged = new ArgumentMetadata(child.position, + child.title, + child.description, + child.sealed, + child.overrides, + child.restrictions.size() > 0 ? child.restrictions : parent.restrictions, + child.provider, + null); + //@formatter:on + + // Combine both child and parent accessors - this is necessary so the + // parsed value propagates to all classes in the hierarchy + Set accessors = new LinkedHashSet<>(child.accessors); + accessors.addAll(parent.accessors); + merged.accessors = AirlineUtils.unmodifiableSetCopy(accessors); + return merged; + } +} diff --git a/airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentsMetadata.java b/airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentsMetadata.java index 0c4849625..257d1b156 100644 --- a/airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentsMetadata.java +++ b/airline-core/src/main/java/com/github/rvesse/airline/model/ArgumentsMetadata.java @@ -28,7 +28,7 @@ import java.util.List; import java.util.Set; -import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.IterableUtils; import org.apache.commons.collections4.IteratorUtils; import org.apache.commons.collections4.ListUtils; import org.apache.commons.collections4.SetUtils; @@ -96,7 +96,7 @@ public String getDescription() { } public boolean isRequired() { - return CollectionUtils.exists(this.restrictions, new IsRequiredArgumentFinder()); + return IterableUtils.matchesAny(this.restrictions, new IsRequiredArgumentFinder()); } public Set getAccessors() { diff --git a/airline-core/src/main/java/com/github/rvesse/airline/model/CommandMetadata.java b/airline-core/src/main/java/com/github/rvesse/airline/model/CommandMetadata.java index e805ab0db..a18aedb93 100644 --- a/airline-core/src/main/java/com/github/rvesse/airline/model/CommandMetadata.java +++ b/airline-core/src/main/java/com/github/rvesse/airline/model/CommandMetadata.java @@ -34,6 +34,7 @@ public class CommandMetadata { private final List groupOptions; private final List commandOptions; private final OptionMetadata defaultOption; + private final List positionalArgs; private final ArgumentsMetadata arguments; private final List metadataInjections; private final Class type; @@ -49,6 +50,7 @@ public CommandMetadata(String name, Iterable groupOptions, Iterable commandOptions, OptionMetadata defaultOption, + List positionalArguments, ArgumentsMetadata arguments, Iterable metadataInjections, Class type, @@ -68,6 +70,7 @@ public CommandMetadata(String name, this.groupOptions = AirlineUtils.unmodifiableListCopy(groupOptions); this.commandOptions = AirlineUtils.unmodifiableListCopy(commandOptions); this.defaultOption = defaultOption; + this.positionalArgs = positionalArguments; this.arguments = arguments; if (this.defaultOption != null && this.arguments != null) { @@ -126,6 +129,10 @@ public List getCommandOptions() { public OptionMetadata getDefaultOption() { return defaultOption; } + + public List getPositionalArguments() { + return positionalArgs; + } public ArgumentsMetadata getArguments() { return arguments; @@ -156,6 +163,7 @@ public String toString() { sb.append(" , globalOptions=").append(globalOptions).append('\n'); sb.append(" , groupOptions=").append(groupOptions).append('\n'); sb.append(" , commandOptions=").append(commandOptions).append('\n'); + sb.append(" , positionalArguments=").append(positionalArgs).append('\n'); sb.append(" , arguments=").append(arguments).append('\n'); sb.append(" , metadataInjections=").append(metadataInjections).append('\n'); sb.append(" , type=").append(type).append('\n'); diff --git a/airline-core/src/main/java/com/github/rvesse/airline/model/MetadataLoader.java b/airline-core/src/main/java/com/github/rvesse/airline/model/MetadataLoader.java index 19fdcd761..0854cf6b4 100644 --- a/airline-core/src/main/java/com/github/rvesse/airline/model/MetadataLoader.java +++ b/airline-core/src/main/java/com/github/rvesse/airline/model/MetadataLoader.java @@ -25,6 +25,7 @@ import com.github.rvesse.airline.annotations.Option; import com.github.rvesse.airline.annotations.OptionType; import com.github.rvesse.airline.annotations.Parser; +import com.github.rvesse.airline.annotations.PositionalArgument; import com.github.rvesse.airline.annotations.restrictions.Partial; import com.github.rvesse.airline.annotations.restrictions.Partials; import com.github.rvesse.airline.builder.ParserBuilder; @@ -484,6 +485,7 @@ public static CommandMetadata loadCommand(Class commandType, Map type, InjectionMetadata inject // nicely with Guice } + // Process Option annotations Option optionAnnotation = field.getAnnotation(Option.class); DefaultOption defaultOptionAnnotation = field.getAnnotation(DefaultOption.class); if (optionAnnotation != null) { @@ -730,7 +733,32 @@ public static void loadInjectionMetadata(Class type, InjectionMetadata inject throw new IllegalArgumentException(String.format( "Field %s annotated with @DefaultOption must also have an @Option annotation", field)); } + + // Process positional arguments annotations + PositionalArgument positionalArgumentAnnotation = field.getAnnotation(PositionalArgument.class); + if (field.isAnnotationPresent(PositionalArgument.class)) { + String title = positionalArgumentAnnotation.title(); + if (StringUtils.isBlank(title)) { + title = field.getName(); + } + + TypeConverterProvider provider = ParserUtil.createInstance(positionalArgumentAnnotation.typeConverterProvider()); + + List restrictions = collectArgumentRestrictions(field); + + //@formatter:off + injectionMetadata.positionalArgs.add(new ArgumentMetadata(positionalArgumentAnnotation.position(), + title, + positionalArgumentAnnotation.description(), + positionalArgumentAnnotation.sealed(), + positionalArgumentAnnotation.override(), + restrictions, + provider, + path)); + //@formatter:on + } + // Process arguments annotation Arguments argumentsAnnotation = field.getAnnotation(Arguments.class); if (field.isAnnotationPresent(Arguments.class)) { // Can't have both @DefaultOption and @Arguments @@ -751,23 +779,7 @@ public static void loadInjectionMetadata(Class type, InjectionMetadata inject TypeConverterProvider provider = ParserUtil .createInstance(argumentsAnnotation.typeConverterProvider()); - Map, Set> partials = loadPartials(field); - List restrictions = new ArrayList<>(); - for (Class annotationClass : RestrictionRegistry - .getArgumentsRestrictionAnnotationClasses()) { - Annotation annotation = field.getAnnotation(annotationClass); - if (annotation == null) - continue; - ArgumentsRestriction restriction = RestrictionRegistry.getArgumentsRestriction(annotationClass, - annotation); - if (restriction != null) { - // Adjust for partial if necessary - if (partials.containsKey(annotationClass)) - restriction = new PartialRestriction(partials.get(annotationClass), restriction); - - restrictions.add(restriction); - } - } + List restrictions = collectArgumentRestrictions(field); //@formatter:off injectionMetadata.arguments.add(new ArgumentsMetadata(titles, @@ -781,6 +793,27 @@ public static void loadInjectionMetadata(Class type, InjectionMetadata inject } } + public static List collectArgumentRestrictions(Field field) { + Map, Set> partials = loadPartials(field); + List restrictions = new ArrayList<>(); + for (Class annotationClass : RestrictionRegistry + .getArgumentsRestrictionAnnotationClasses()) { + Annotation annotation = field.getAnnotation(annotationClass); + if (annotation == null) + continue; + ArgumentsRestriction restriction = RestrictionRegistry.getArgumentsRestriction(annotationClass, + annotation); + if (restriction != null) { + // Adjust for partial if necessary + if (partials.containsKey(annotationClass)) + restriction = new PartialRestriction(partials.get(annotationClass), restriction); + + restrictions.add(restriction); + } + } + return restrictions; + } + private static Map, Set> loadPartials(Field field) { Map, Set> partials = new HashMap<>(); @@ -904,6 +937,69 @@ private static void tryOverrideOptions(Map, OptionMetadata> optionIn optionIndex.put(names, merged); } + private static List overridePositionalArgumentSet(List args) { + args = ListUtils.unmodifiableList(args); + + Map argsIndex = new HashMap<>(); + int maxIndex = -1; + for (ArgumentMetadata arg : args) { + maxIndex = Math.max(maxIndex, arg.getZeroBasedPosition()); + + if (argsIndex.containsKey(arg.getZeroBasedPosition())) { + tryOverridePositionalArgument(argsIndex, arg); + } else { + // First argument for this positional index we've seen + argsIndex.put(arg.getZeroBasedPosition(), arg); + } + } + + List posArgs = new ArrayList<>(maxIndex); + for (int i = 0; i < maxIndex; i++) { + posArgs.set(i, argsIndex.get(i)); + if (posArgs.get(i) == null) { + throw new IllegalStateException(String.format( + "Gap in positional arguments, missing argument at position %d the following positional arguments are present: %s", + i, StringUtils.join(argsIndex.keySet(), ", "))); + } + } + + return ListUtils.unmodifiableList(posArgs); + } + + private static void tryOverridePositionalArgument(Map argsIndex, + ArgumentMetadata parent) { + + // As the metadata is extracted from the deepest class in the hierarchy + // going upwards we need to treat the passed option as the parent and + // the pre-existing option definition as the child + ArgumentMetadata child = argsIndex.get(parent.getZeroBasedPosition()); + + Accessor parentField = parent.getAccessors().iterator().next(); + Accessor childField = child.getAccessors().iterator().next(); + + // Check for duplicates + boolean isDuplicate = parent == child || parent.equals(child); + + // Parent must not state it is sealed UNLESS it is a duplicate which can + // happen when using @Inject to inject options via delegates + if (parent.isSealed() && !isDuplicate) + throw new IllegalArgumentException(String.format( + "Fields %s and %s have conflicting definitions of positional argument %d (%s) - parent field %s declares itself as sealed and cannot be overridden", + parentField, childField, parent.getZeroBasedPosition(), parent.getTitle(), parentField)); + + // Child must explicitly state that it overrides otherwise we cannot + // override UNLESS it is the case that this is a duplicate which + // can happen when using @Inject to inject options via delegates + if (!child.isOverride() && !isDuplicate) + throw new IllegalArgumentException(String.format( + "Fields %s and %s have conflicting definitions of positional argument %d (%s) - if you wanted to override this argument you must explicitly specify override = true in your child field annotation", + parentField, childField, parent.getZeroBasedPosition(), parent.getTitle())); + + // Attempt overriding, this will error if the overriding is not possible + ArgumentMetadata merged = ArgumentMetadata.override(parent, child); + argsIndex.put(parent.getZeroBasedPosition(), merged); + } + public static void loadCommandsIntoGroupsByAnnotation(List allCommands, List commandGroups, List defaultCommandGroup, Map baseHelpSections) { @@ -1107,8 +1203,11 @@ private static class InjectionMetadata { private List groupOptions = new ArrayList<>(); private List commandOptions = new ArrayList<>(); private OptionMetadata defaultOption = null; + private List positionalArgs = new ArrayList<>(); private List arguments = new ArrayList<>(); private List metadataInjections = new ArrayList<>(); + + private void compact() { globalOptions = overrideOptionSet(globalOptions); @@ -1129,6 +1228,8 @@ private void compact() { } } + positionalArgs = overridePositionalArgumentSet(positionalArgs); + if (arguments.size() > 1) { arguments = ListUtils.unmodifiableList(Collections.singletonList(new ArgumentsMetadata(arguments))); } diff --git a/airline-core/src/main/java/com/github/rvesse/airline/model/OptionMetadata.java b/airline-core/src/main/java/com/github/rvesse/airline/model/OptionMetadata.java index 822e3fb14..6b3dba8b4 100644 --- a/airline-core/src/main/java/com/github/rvesse/airline/model/OptionMetadata.java +++ b/airline-core/src/main/java/com/github/rvesse/airline/model/OptionMetadata.java @@ -29,7 +29,7 @@ import java.util.List; import java.util.Set; -import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.IterableUtils; import org.apache.commons.collections4.SetUtils; public class OptionMetadata { @@ -131,7 +131,7 @@ public int getArity() { } public boolean isRequired() { - return CollectionUtils.exists(this.restrictions, new IsRequiredOptionFinder()); + return IterableUtils.matchesAny(this.restrictions, new IsRequiredOptionFinder()); } public boolean isHidden() { @@ -248,7 +248,6 @@ public String toString() { *
    *
  • Title
  • *
  • Description
  • - *
  • Required
  • *
  • Hidden
  • *
*